diff --git a/.gitignore b/.gitignore index dd1118ac1..6219423ec 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ src/renderer/**/__screenshots__/**/*.png !src/renderer/**/__screenshots__/**/*-chromium-linux.png # Actual/diff images written by a failing visual regression run +.vitest/ .vitest-attachments/ # Mac Files diff --git a/src/renderer/App.css b/src/renderer/App.css index 5856c7929..b907eca34 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -495,6 +495,7 @@ html, body, #root { height: 100%; + overflow: hidden; -webkit-user-select: none; user-select: none; } @@ -578,6 +579,19 @@ body, border-radius: 0; } +/* Settings rows are `
`s so their controls stay semantically grouped, + * but Primer's global stylesheet resets `fieldset { padding: 0; border: 0 }` + * outside Tailwind's cascade layers. Unlayered rules win over the layered + * utilities, which stripped each row's vertical padding and divider. Keep the + * row rhythm here, unlayered, so it survives that reset. */ +.settings-list > * { + padding-block: var(--base-size-8, 8px); +} + +.settings-list > :not(:last-child) { + border-bottom: 1px solid var(--borderColor-disabled); +} + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', diff --git a/src/renderer/components/fields/RadioGroup.test.tsx b/src/renderer/components/fields/RadioGroup.test.tsx deleted file mode 100644 index a2c279545..000000000 --- a/src/renderer/components/fields/RadioGroup.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderWithProviders } from '../../__helpers__/test-utils'; - -import { RadioGroup, type RadioGroupProps } from './RadioGroup'; - -describe('renderer/components/fields/RadioGroup.tsx', () => { - const props: RadioGroupProps = { - label: 'Appearance', - name: 'appearance', - options: [ - { label: 'Value 1', value: 'one' }, - { label: 'Value 2', value: 'two' }, - ], - onChange: vi.fn(), - value: 'two', - }; - - it('should render', () => { - const tree = renderWithProviders(); - expect(tree.container).toMatchSnapshot(); - }); - - it('should render as disabled', () => { - const mockProps = { ...props, disabled: true }; - - const tree = renderWithProviders(); - expect(tree.container).toMatchSnapshot(); - }); -}); diff --git a/src/renderer/components/fields/RadioGroup.tsx b/src/renderer/components/fields/RadioGroup.tsx deleted file mode 100644 index 65122375f..000000000 --- a/src/renderer/components/fields/RadioGroup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ChangeEvent, ReactNode } from 'react'; - -import { Stack } from '@primer/react'; - -import type { RadioGroupItem } from '../../types'; - -import { FieldLabel } from './FieldLabel'; -import { Tooltip } from './Tooltip'; - -export interface RadioGroupProps { - name: string; - label: string; - options: RadioGroupItem[]; - value: string; - onChange: (event: ChangeEvent) => void; - tooltip?: ReactNode | string; -} - -export function RadioGroup(props: Readonly) { - const firstOptionId = props.options[0] - ? `radio-${props.name}-${props.options[0].value.toLowerCase()}` - : props.name; - - return ( - - - - {props.options.map((item) => { - const name = `radio-${props.name}-${item.value.toLowerCase()}`; - - return ( - - - - - ); - })} - - {props.tooltip && } - - ); -} diff --git a/src/renderer/components/fields/__snapshots__/RadioGroup.test.tsx.snap b/src/renderer/components/fields/__snapshots__/RadioGroup.test.tsx.snap deleted file mode 100644 index e818fbc95..000000000 --- a/src/renderer/components/fields/__snapshots__/RadioGroup.test.tsx.snap +++ /dev/null @@ -1,147 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`renderer/components/fields/RadioGroup.tsx > should render 1`] = ` -
-
- -
- - -
-
- - -
-
-
-`; - -exports[`renderer/components/fields/RadioGroup.tsx > should render as disabled 1`] = ` -
-
- -
- - -
-
- - -
-
-
-`; diff --git a/src/renderer/components/layout/Contents.tsx b/src/renderer/components/layout/Contents.tsx index f04a90353..df7cd22e4 100644 --- a/src/renderer/components/layout/Contents.tsx +++ b/src/renderer/components/layout/Contents.tsx @@ -22,7 +22,7 @@ export function Contents({ return (
should render itself & its children 1`] = `
Test
@@ -13,7 +13,7 @@ exports[`renderer/components/layout/Contents.tsx > should render itself & its ch exports[`renderer/components/layout/Contents.tsx > should render with scroll fade 1`] = `
Test
diff --git a/src/renderer/components/settings/AlertSettings.test.tsx b/src/renderer/components/settings/AlertSettings.test.tsx index e75be63fd..760d0ce53 100644 --- a/src/renderer/components/settings/AlertSettings.test.tsx +++ b/src/renderer/components/settings/AlertSettings.test.tsx @@ -1,4 +1,4 @@ -import { act, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; @@ -20,79 +20,75 @@ describe('renderer/components/settings/AlertSettings.tsx', () => { }); it.each([ - ['checkbox-showNotifications', 'showNotifications'], - ['checkbox-playSound', 'playSound'], - ['checkbox-showUpdateNotifications', 'showUpdateNotifications'], - ] as const)('should toggle %s checkbox', async (testId, setting) => { - await act(async () => { - renderWithProviders(); - }); + ['Show desktop notifications', 'showNotifications'], + ['Play notification sound', 'playSound'], + ['Show app update notifications', 'showUpdateNotifications'], + ] as const)('should toggle %s switch', async (label, setting) => { + renderWithProviders(); - await userEvent.click(screen.getByTestId(testId)); + await userEvent.click(screen.getByRole('button', { name: label })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); - describe('playSound', () => { - it('should explain when the sound plays and what the volume controls do', async () => { - renderWithProviders(); + it('shows detailed help in tooltips', async () => { + renderWithProviders(); - const soundHelp = - 'Play a sound when new unread notifications arrive after filtering. Use the adjacent volume controls to adjust the sound level.'; + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-playSound')); - const tooltipTrigger = screen.getByRole('button', { name: 'tooltip-playSound' }); + expect( + screen.getByText( + 'Play a sound when new unread notifications arrive after filtering. Use the notification volume controls to adjust the sound level.', + ), + ).toBeInTheDocument(); + }); - expect(screen.queryByText(soundHelp)).not.toBeInTheDocument(); + it('shows notification volume and its tooltip when sound is enabled', async () => { + renderWithProviders(); - await userEvent.click(tooltipTrigger); + expect(screen.getByText('Notification volume')).toBeInTheDocument(); + expect(screen.getByTestId('reveal-volume')).toHaveAttribute('data-state', 'open'); - expect(screen.getByText(soundHelp)).toBeInTheDocument(); - }); + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-notificationVolume')); - it('volume controls should not be shown if playSound checkbox is false', async () => { - renderWithProviders(, { - settings: { ...mockSettings, playSound: false }, - }); + expect(screen.getByText('Adjust the volume of the notification sound.')).toBeInTheDocument(); + }); - expect(screen.queryByTestId('settings-volume-group')).not.toBeInTheDocument(); + it('hides volume controls when playSound is false', () => { + renderWithProviders(, { + settings: { ...mockSettings, playSound: false }, }); - it('volume controls should be shown if playSound checkbox is true', async () => { - renderWithProviders(, { - settings: { ...mockSettings, playSound: true }, - }); + expect(screen.getByTestId('reveal-volume')).toHaveAttribute('data-state', 'closed'); + }); - expect(screen.getByTestId('settings-volume-group')).toBeInTheDocument(); - }); + it('should increase notification volume', async () => { + renderWithProviders(); - it('should increase notification volume', async () => { - renderWithProviders(); + await userEvent.click(screen.getByTestId('settings-volume-up')); - await userEvent.click(screen.getByTestId('settings-volume-up')); + expect(updateSettingSpy).toHaveBeenCalledTimes(1); + expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 30); + }); - expect(updateSettingSpy).toHaveBeenCalledTimes(1); - expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 30); - }); + it('should decrease notification volume', async () => { + renderWithProviders(); - it('should decrease notification volume', async () => { - renderWithProviders(); + await userEvent.click(screen.getByTestId('settings-volume-down')); - await userEvent.click(screen.getByTestId('settings-volume-down')); + expect(updateSettingSpy).toHaveBeenCalledTimes(1); + expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 10); + }); - expect(updateSettingSpy).toHaveBeenCalledTimes(1); - expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 10); + it('should reset notification volume', async () => { + renderWithProviders(, { + settings: { ...mockSettings, notificationVolume: 30 as Percentage }, }); - it('should reset notification volume', async () => { - renderWithProviders(, { - settings: { ...mockSettings, notificationVolume: 30 as Percentage }, - }); + await userEvent.click(screen.getByTestId('settings-volume-reset')); - await userEvent.click(screen.getByTestId('settings-volume-reset')); - - expect(updateSettingSpy).toHaveBeenCalledTimes(1); - expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 20); - }); + expect(updateSettingSpy).toHaveBeenCalledTimes(1); + expect(updateSettingSpy).toHaveBeenCalledWith('notificationVolume', 20); }); }); diff --git a/src/renderer/components/settings/AlertSettings.tsx b/src/renderer/components/settings/AlertSettings.tsx index b49381303..4b725b10d 100644 --- a/src/renderer/components/settings/AlertSettings.tsx +++ b/src/renderer/components/settings/AlertSettings.tsx @@ -1,11 +1,10 @@ import { BellIcon, SyncIcon } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; +import { Button, ButtonGroup, IconButton } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; import { DEFAULT_SETTINGS_STATE, useSettingsStore } from '../../stores'; -import { Checkbox } from '../fields/Checkbox'; import { SectionHeading } from '../primitives/SectionHeading'; import { VolumeDownIcon } from '../../icons/VolumeDownIcon'; @@ -16,6 +15,10 @@ import { decreaseVolume, increaseVolume, } from '../../utils/ui/volume'; +import { SettingsList } from './controls/SettingsList'; +import { SettingsReveal } from './controls/SettingsReveal'; +import { SettingsRow } from './controls/SettingsRow'; +import { SettingsToggle } from './controls/SettingsToggle'; const defaultSettings = DEFAULT_SETTINGS_STATE; @@ -34,33 +37,30 @@ export function AlertSettings() { Alerts - - + toggleSetting('showNotifications')} - tooltip={ - Display native operating system notifications for new unread notifications. - } + tooltip="Display native operating system notifications for new unread notifications." /> - - toggleSetting('playSound')} - tooltip={ - - Play a sound when new unread notifications arrive after filtering. Use the adjacent - volume controls to adjust the sound level. - - } - /> + toggleSetting('playSound')} + tooltip="Play a sound when new unread notifications arrive after filtering. Use the notification volume controls to adjust the sound level." + /> - {playSound && ( - + + + - )} - + + - toggleSetting('showUpdateNotifications')} - tooltip={ - - Show a notification when a {APPLICATION.NAME} update is ready. Updates will still be - checked and shown in the menu bar. - - } + tooltip={`Show a notification when a ${APPLICATION.NAME} update is ready. Updates are still checked and shown in the menu bar.`} /> - +
); } diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index be0e6e1e3..15717342f 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -24,7 +24,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { renderWithProviders(); }); - await userEvent.selectOptions(screen.getByTestId('settings-theme'), 'LIGHT'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Theme' }), 'LIGHT'); expect(updateSettingSpy).toHaveBeenCalledTimes(1); expect(updateSettingSpy).toHaveBeenCalledWith('theme', 'LIGHT'); @@ -35,7 +35,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { renderWithProviders(); }); - await userEvent.selectOptions(screen.getByTestId('settings-design-language'), 'glass'); + await userEvent.click(screen.getByTestId('choice-designLanguage-glass')); expect(updateSettingSpy).toHaveBeenCalledTimes(1); expect(updateSettingSpy).toHaveBeenCalledWith('designLanguage', DesignLanguage.GLASS); @@ -85,7 +85,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId('checkbox-increaseContrast')); + await userEvent.click(screen.getByRole('button', { name: 'Increase contrast' })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith('increaseContrast'); @@ -98,7 +98,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { }); }); - expect(screen.queryByTestId('checkbox-increaseContrast')).not.toBeInTheDocument(); + expect(screen.queryByTestId('switch-increaseContrast')).not.toBeInTheDocument(); }); it('should toggle the status icon colors checkbox for Glass', async () => { @@ -108,7 +108,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { }); }); - await userEvent.click(screen.getByTestId('checkbox-showStatusIconColors')); + await userEvent.click(screen.getByRole('button', { name: 'Show status icon colors' })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith('showStatusIconColors'); @@ -119,29 +119,33 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { renderWithProviders(); }); - expect(screen.queryByTestId('checkbox-showStatusIconColors')).not.toBeInTheDocument(); + expect(screen.queryByTestId('switch-showStatusIconColors')).not.toBeInTheDocument(); }); - it.each([ - [ - 'tooltip-icon-tooltip-design-language', - 'Choose Classic for the original Gitify layout, or Glass for the translucent, layered design language.', - ], - [ - 'tooltip-icon-tooltip-theme', - 'Choose a light, dark, or system color mode. Available options depend on the selected design language.', - ], - [ - 'tooltip-icon-tooltip-zoom', - 'Change the size of the Gitify interface. The setting applies immediately and is remembered for the next launch.', - ], - ])('should show help for %s', async (testId, help) => { + it('shows detailed help in the tooltips', async () => { await act(async () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId(testId)); - - expect(screen.getByText(help)).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-designLanguage')); + expect( + screen.getByText( + 'Choose Classic for the original Gitify layout, or Glass for the translucent, layered design language.', + ), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-theme')); + expect( + screen.getByText( + 'Choose a light, dark, or system color mode. Available options depend on the selected design language.', + ), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-zoom')); + expect( + screen.getByText( + 'Change the size of the Gitify interface. The setting applies immediately and is remembered for the next launch.', + ), + ).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 9da215fd7..31ac68187 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,12 +1,10 @@ import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Select, Stack, Text } from '@primer/react'; +import { Button, ButtonGroup, IconButton, Select } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; import { useSettingsStore } from '../../stores'; -import { Checkbox } from '../fields/Checkbox'; -import { FieldLabel } from '../fields/FieldLabel'; import { SectionHeading } from '../primitives/SectionHeading'; import { DesignLanguage, Theme } from '../../types'; @@ -19,6 +17,10 @@ import { increaseZoom, resetZoomLevel, } from '../../utils/ui/zoom'; +import { SettingsChoice } from './controls/SettingsChoice'; +import { SettingsList } from './controls/SettingsList'; +import { SettingsRow } from './controls/SettingsRow'; +import { SettingsToggle } from './controls/SettingsToggle'; export function AppearanceSettings() { // Setting store actions @@ -40,112 +42,80 @@ export function AppearanceSettings() { Appearance - - - - Choose Classic for the original {APPLICATION.NAME} layout, or Glass for the - translucent, layered design language. - - } - /> - - - - - - Choose a light, dark, or system color mode. Available options depend on the selected - design language. - - } - /> - - - - + updateSetting('designLanguage', value as DesignLanguage)} + options={[ + { label: 'Classic', value: DesignLanguage.CLASSIC }, + { label: 'Glass', value: DesignLanguage.GLASS }, + ]} + tooltip={`Choose Classic for the original ${APPLICATION.NAME} layout, or Glass for the translucent, layered design language.`} + value={designLanguage} + /> + + updateSetting('theme', value as Theme)} + presentation="select" + selectChildren={ + <> + + System + + + Light default + {colorModeSupported(Theme.LIGHT_COLORBLIND) && ( + Light colorblind + )} + {colorModeSupported(Theme.LIGHT_TRITANOPIA) && ( + Light Tritanopia + )} + + + Dark default + {colorModeSupported(Theme.DARK_COLORBLIND) && ( + Dark colorblind + )} + {colorModeSupported(Theme.DARK_TRITANOPIA) && ( + Dark Tritanopia + )} + {colorModeSupported(Theme.DARK_DIMMED) && ( + Soft dark + )} + + + } + tooltip="Choose a light, dark, or system color mode. Available options depend on the selected design language." + value={resolveColorMode(designLanguage, theme)} + /> + + toggleSetting('increaseContrast')} - tooltip={ - - Use GitHub's high-contrast color schemes for improved legibility. Also applied - automatically when your system's increase-contrast setting is enabled. - - } + tooltip="Use GitHub's high-contrast color schemes for improved legibility. Also applied automatically when your system's increase-contrast setting is enabled." visible={designLanguage === DesignLanguage.CLASSIC} /> - toggleSetting('showStatusIconColors')} - tooltip={ - - Color the notification type icons by their state (open, closed, done, attention). - Glass renders them monochrome by default. - - } + tooltip="Color the notification type icons by their state (open, closed, done, attention). Glass renders them monochrome by default." visible={designLanguage === DesignLanguage.GLASS} /> - - - Change the size of the Gitify interface. The setting applies immediately and is - remembered for the next launch. - - } - /> - - + + - - + + ); } diff --git a/src/renderer/components/settings/InboxSettings.test.tsx b/src/renderer/components/settings/InboxSettings.test.tsx index 9e0145de4..3a9538e0c 100644 --- a/src/renderer/components/settings/InboxSettings.test.tsx +++ b/src/renderer/components/settings/InboxSettings.test.tsx @@ -1,7 +1,7 @@ import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../../__helpers__/test-utils'; +import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; import { mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, @@ -23,14 +23,15 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { beforeEach(() => { toggleSettingSpy = vi.spyOn(useSettingsStore.getState(), 'toggleSetting'); updateSettingSpy = vi.spyOn(useSettingsStore.getState(), 'updateSetting'); + navigateMock.mockReset(); }); - it('should change the groupBy radio group', async () => { + it('should change the groupBy choice', async () => { await act(async () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId('radio-groupBy-date')); + await userEvent.click(screen.getByTestId('choice-groupBy-date')); expect(updateSettingSpy).toHaveBeenCalledTimes(1); expect(updateSettingSpy).toHaveBeenCalledWith('groupBy', 'DATE'); @@ -42,7 +43,6 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { renderWithProviders(); }); - // Increase fetch interval await act(async () => { await userEvent.click(screen.getByTestId('settings-fetch-interval-increase')); @@ -57,7 +57,6 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenNthCalledWith(2, 'fetchInterval', 180000); }); - // Decrease fetch interval await act(async () => { await userEvent.click(screen.getByTestId('settings-fetch-interval-decrease')); @@ -65,7 +64,6 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenNthCalledWith(3, 'fetchInterval', 120000); }); - // Fetch interval reset await act(async () => { await userEvent.click(screen.getByTestId('settings-fetch-interval-reset')); @@ -93,7 +91,6 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenCalledWith('fetchInterval', 60000); }); - // Attempt to go below the minimum interval, update settings should not be called await act(async () => { await userEvent.click(screen.getByTestId('settings-fetch-interval-decrease')); @@ -120,7 +117,6 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenCalledWith('fetchInterval', 3600000); }); - // Attempt to go above the maximum interval, update settings should not be called await act(async () => { await userEvent.click(screen.getByTestId('settings-fetch-interval-increase')); @@ -130,38 +126,38 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); it.each([ - ['checkbox-fetchAllNotifications', 'fetchAllNotifications'], - ['checkbox-detailedNotifications', 'detailedNotifications'], - ['checkbox-showPills', 'showPills'], - ['checkbox-showNumber', 'showNumber'], - ['checkbox-showOnlyParticipating', 'participating'], - ['checkbox-fetchReadNotifications', 'fetchReadNotifications'], - ['checkbox-markAsDoneOnOpen', 'markAsDoneOnOpen'], - ['checkbox-markAsDoneOnUnsubscribe', 'markAsDoneOnUnsubscribe'], - ['checkbox-showAccountHeader', 'showAccountHeader'], - ['checkbox-wrapNotificationTitle', 'wrapNotificationTitle'], - ['checkbox-delayNotificationState', 'delayNotificationState'], - ] as const)('should toggle %s checkbox', async (testId, setting) => { + ['Fetch all notifications', 'fetchAllNotifications'], + ['Fetch detailed notifications', 'detailedNotifications'], + ['Show notification metrics', 'showPills'], + ['Show reference', 'showNumber'], + ['Fetch only participating', 'participating'], + ['Fetch read and done notifications', 'fetchReadNotifications'], + ['Mark as done on open', 'markAsDoneOnOpen'], + ['Mark as done on unsubscribe', 'markAsDoneOnUnsubscribe'], + ['Show account header', 'showAccountHeader'], + ['Show full notification title', 'wrapNotificationTitle'], + ['Delay notification removal', 'delayNotificationState'], + ] as const)('should toggle %s switch', async (label, setting) => { await act(async () => { renderWithProviders(, { accounts: [mockGitHubCloudAccount], }); }); - await userEvent.click(screen.getByTestId(testId)); + await userEvent.click(screen.getByRole('button', { name: label })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); - it('reactively hides the account header checkbox when a second account is added', async () => { + it('reactively hides the account header switch when a second account is added', async () => { await act(async () => { renderWithProviders(, { accounts: [mockGitHubCloudAccount], }); }); - expect(screen.getByTestId('checkbox-showAccountHeader')).toBeInTheDocument(); + expect(screen.getByTestId('switch-showAccountHeader')).toBeInTheDocument(); await act(async () => { useAccountsStore.setState({ @@ -169,7 +165,7 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - expect(screen.queryByTestId('checkbox-showAccountHeader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('switch-showAccountHeader')).not.toBeInTheDocument(); }); describe('GitHub-specific settings', () => { @@ -180,7 +176,7 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - expect(screen.queryByTestId('checkbox-showOnlyParticipating')).not.toBeInTheDocument(); + expect(screen.queryByTestId('switch-showOnlyParticipating')).not.toBeInTheDocument(); expect( screen.getByText('GitHub-specific options require a GitHub account.'), ).toBeInTheDocument(); @@ -193,13 +189,17 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - expect(screen.getByTestId('checkbox-markAsDoneOnOpen')).toBeDisabled(); - expect(screen.getByTestId('checkbox-markAsDoneOnUnsubscribe')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Mark as done on open' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('button', { name: 'Mark as done on unsubscribe' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); expect( - screen.getByText( - 'Mark as done requires GitHub Cloud or GitHub Enterprise Server 3.13 or later.', - ), - ).toBeInTheDocument(); + screen.getAllByText('Requires GitHub Cloud or GitHub Enterprise Server 3.13 or later.'), + ).toHaveLength(2); }); it('hides notification metrics when detailed notifications are disabled', async () => { @@ -210,7 +210,10 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - expect(screen.queryByTestId('checkbox-showPills')).not.toBeInTheDocument(); + expect(screen.getByTestId('reveal-notificationMetrics')).toHaveAttribute( + 'data-state', + 'closed', + ); }); it('shows notification metrics when detailed notifications are enabled', async () => { @@ -221,11 +224,33 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - expect(screen.getByTestId('checkbox-showPills')).toBeInTheDocument(); + expect(screen.getByTestId('reveal-notificationMetrics')).toHaveAttribute( + 'data-state', + 'open', + ); + }); + + it('shows a banner when GitHub accounts are missing scopes', async () => { + await act(async () => { + renderWithProviders(, { + accounts: [mockGitHubCloudAccount], + }); + }); + + expect(screen.getByTestId('settings-scope-warning')).toBeInTheDocument(); + expect( + screen.getByText( + 'One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete.', + ), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Review account scopes' })); + + expect(navigateMock).toHaveBeenCalledWith('/accounts'); }); }); - it('should open official docs for showOnlyParticipating tooltip', async () => { + it('opens official docs from the participating help link', async () => { const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); await act(async () => { @@ -234,9 +259,8 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { }); }); - const tooltipElement = screen.getByLabelText('tooltip-showOnlyParticipating'); + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-showOnlyParticipating')); - await userEvent.click(tooltipElement); await userEvent.click( screen.getByTitle('Open GitHub documentation for participating and watching notifications'), ); @@ -247,7 +271,23 @@ describe('renderer/components/settings/InboxSettings.tsx', () => { ); }); - it('should show help for the fetch interval', async () => { + it.each([ + ['groupBy', 'tooltip-groupBy'], + ['showAccountHeader', 'tooltip-showAccountHeader'], + ['wrapNotificationTitle', 'tooltip-wrapNotificationTitle'], + ['showNumber', 'tooltip-showNumber'], + ['delayNotificationState', 'tooltip-delayNotificationState'], + ] as const)('renders the %s tooltip', async (_name, tooltipName) => { + await act(async () => { + renderWithProviders(, { + accounts: [mockGitHubCloudAccount], + }); + }); + + expect(screen.getByTestId(`tooltip-icon-${tooltipName}`)).toBeInTheDocument(); + }); + + it('shows the fetch interval details in a tooltip', async () => { await act(async () => { renderWithProviders(); }); diff --git a/src/renderer/components/settings/InboxSettings.tsx b/src/renderer/components/settings/InboxSettings.tsx index ae5c79733..d651e07d6 100644 --- a/src/renderer/components/settings/InboxSettings.tsx +++ b/src/renderer/components/settings/InboxSettings.tsx @@ -2,7 +2,6 @@ import type { MouseEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { - AlertFillIcon, CheckIcon, CommentIcon, DashIcon, @@ -20,20 +19,15 @@ import { SyncIcon, TagIcon, } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; +import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; import { formatDuration } from 'date-fns/formatDuration'; import { millisecondsToMinutes } from 'date-fns/millisecondsToMinutes'; -import { APPLICATION } from '../../../shared/constants'; - import { Constants } from '../../constants'; import { useAccountsStore, useSettingsStore } from '../../stores'; -import { Checkbox } from '../fields/Checkbox'; -import { FieldLabel } from '../fields/FieldLabel'; -import { RadioGroup } from '../fields/RadioGroup'; import { SectionHeading } from '../primitives/SectionHeading'; import { GroupBy, Size } from '../../types'; @@ -41,6 +35,11 @@ import { GroupBy, Size } from '../../types'; import { isMarkAsDoneFeatureSupported } from '../../utils/api/features'; import { hasAlternateScopes, hasRecommendedScopes } from '../../utils/auth/scopes'; import { openGitHubParticipatingDocs } from '../../utils/system/links'; +import { SettingsChoice } from './controls/SettingsChoice'; +import { SettingsList } from './controls/SettingsList'; +import { SettingsReveal } from './controls/SettingsReveal'; +import { SettingsRow } from './controls/SettingsRow'; +import { SettingsToggle } from './controls/SettingsToggle'; import { SettingsGroupHeading } from './SettingsGroupHeading'; export function InboxSettings() { @@ -77,414 +76,355 @@ export function InboxSettings() { Inbox - - Fetching +
+ Fetch - - + + + { + const newInterval = Math.max( + fetchInterval - Constants.FETCH_NOTIFICATIONS_INTERVAL_STEP_MS, + Constants.MIN_FETCH_NOTIFICATIONS_INTERVAL_MS, + ); + + if (newInterval !== fetchInterval) { + updateSetting('fetchInterval', newInterval); + } + }} + size="small" + unsafeDisableTooltip={true} + /> + + + + { + const newInterval = Math.min( + fetchInterval + Constants.FETCH_NOTIFICATIONS_INTERVAL_STEP_MS, + Constants.MAX_FETCH_NOTIFICATIONS_INTERVAL_MS, + ); + + if (newInterval !== fetchInterval) { + updateSetting('fetchInterval', newInterval); + } + }} + size="small" + unsafeDisableTooltip={true} + /> + + { + updateSetting('fetchInterval', Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS); + }} + size="small" + unsafeDisableTooltip={true} + variant="danger" + /> + + + + toggleSetting('fetchAllNotifications')} tooltip={ - - Check for new notifications at this interval. Shorter intervals keep the list - fresher but spend more API requests. - + + When checked, fetch all notifications from your inbox. + When unchecked, fetch only the first page of notifications. + } /> - - { - const newInterval = Math.max( - fetchInterval - Constants.FETCH_NOTIFICATIONS_INTERVAL_STEP_MS, - Constants.MIN_FETCH_NOTIFICATIONS_INTERVAL_MS, - ); - - if (newInterval !== fetchInterval) { - updateSetting('fetchInterval', newInterval); - } - }} - size="small" - unsafeDisableTooltip={true} - /> - - - - { - const newInterval = Math.min( - fetchInterval + Constants.FETCH_NOTIFICATIONS_INTERVAL_STEP_MS, - Constants.MAX_FETCH_NOTIFICATIONS_INTERVAL_MS, - ); - - if (newInterval !== fetchInterval) { - updateSetting('fetchInterval', newInterval); - } - }} - size="small" - unsafeDisableTooltip={true} - /> - - { - updateSetting('fetchInterval', Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS); - }} - size="small" - unsafeDisableTooltip={true} - variant="danger" - /> - - - - toggleSetting('fetchAllNotifications')} - tooltip={ - - - When checked, {APPLICATION.NAME} will fetch{' '} - all notifications from your inbox. - - - When unchecked, {APPLICATION.NAME} will fetch the first page of - notifications. - - - } - /> - - toggleSetting('fetchReadNotifications')} - tooltip={ - - Fetch all notifications including read and done. - - ⚠️ GitHub's API does not distinguish between read and done states, so 'Mark as done' - actions will be unavailable when this setting is enabled. - - - ⚠️ Enabling this setting will increase API usage and may cause rate limiting for - users with many notifications. - - - } - /> + toggleSetting('fetchReadNotifications')} + tooltip={ + + Fetch all notifications including read and done. + + GitHub's API does not distinguish between read and done states, so Mark as done + actions are unavailable while this is enabled. + + + This increases API usage and may cause rate limiting for users with many + notifications. + + + } + /> + List - { - updateSetting('groupBy', evt.target.value as GroupBy); - }} - options={[ - { label: 'Repository', value: GroupBy.REPOSITORY }, - { label: 'Date', value: GroupBy.DATE }, - ]} - tooltip={ - - Choose how notifications are displayed in the list. - - Repository groups notifications by their repository full - name. - - - Date shows notifications in chronological order. - - - } - value={groupBy} - /> - - toggleSetting('showAccountHeader')} - tooltip={ - Show your avatar, username, and quick links above the notifications list. - } - visible={!hasMultipleAccounts} - /> - - toggleSetting('wrapNotificationTitle')} - tooltip={ - - Wrap long notification titles onto multiple lines instead of truncating them. - - } - /> - - toggleSetting('showNumber')} - tooltip={ - - Show the notification reference where available: -
- - - - Discussion - #123 - + + updateSetting('groupBy', value as GroupBy)} + options={[ + { label: 'Repository', value: GroupBy.REPOSITORY }, + { label: 'Date', value: GroupBy.DATE }, + ]} + tooltip={ + + Choose how notifications are displayed in the list. + + Repository groups notifications by repository name. + + + Date shows notifications in chronological order. + + + } + value={groupBy} + /> - - - Issue - #123 - + toggleSetting('showAccountHeader')} + tooltip="Show your avatar, username, and quick links above the notifications list." + visible={!hasMultipleAccounts} + /> - - - Pull Request - #123 - + toggleSetting('wrapNotificationTitle')} + tooltip="Wrap long notification titles onto multiple lines instead of truncating them." + /> - - - Commit - a1b2c3d + toggleSetting('showNumber')} + tooltip={ + + Show the reference where available: +
+ + + + Discussion + #123 + + + + Issue + #123 + + + + Pull request + #123 + + + + Commit + a1b2c3d + + + + Release + v1.2.3 + +
+
+ } + /> - - - Release - v1.2.3 - -
-
-
- } - /> - - toggleSetting('delayNotificationState')} - tooltip={ - - Keep the notification within {APPLICATION.NAME} upon interaction (ie: open - notification, mark as read, mark as done) until the next refresh window (scheduled or - user initiated). - - } - /> + toggleSetting('delayNotificationState')} + tooltip="Keep the notification in Gitify after interacting with it until the next refresh window." + /> + {hasGitHubAccount ? ( <> GitHub - toggleSetting('participating')} - tooltip={ - - - When checked, {APPLICATION.NAME} will fetch only - participating notifications. - - - When unchecked, {APPLICATION.NAME} will fetch participating - and watching notifications. - - - See{' '} + + toggleSetting('participating')} + tooltip={ + + When checked, fetch only participating notifications. + When unchecked, fetch participating and watching notifications. {' '} - for more details. - - - } - /> - - toggleSetting('detailedNotifications')} - tooltip={ - - - When checked, {APPLICATION.NAME} will enrich notifications - with detailed user and state information. You may also choose to display{' '} - notification metrics. - - - When unchecked, {APPLICATION.NAME} will only fetch basic - notification details. - - - ⚠️ Users with a large number of unread notifications may experience rate - limiting under certain circumstances. Please disable this setting if you - experience this. - - - } - /> - - {detailedNotifications && - accounts.some( - (account) => - account.forge === 'github' && - !hasRecommendedScopes(account) && - !hasAlternateScopes(account), - ) && ( - - + Learn more + + + } + /> + + toggleSetting('detailedNotifications')} + tooltip={ - One or more accounts are missing scopes required for notification enrichment. - Notification details may be incomplete. + Enrich notifications with detailed user and state information. You may also + choose to display notification metrics. - - Visit{' '} - {' '} - to review. + + Users with a large number of unread notifications may experience rate + limiting. - - )} - - {detailedNotifications && ( -
- toggleSetting('showPills')} - tooltip={ - - Show notification metrics for: -
- - - - linked issues - - - - reactions - - - - pull request reviews - - - - comments - - - - labels - - - - milestones + } + /> + + {detailedNotifications && + accounts.some( + (account) => + account.forge === 'github' && + !hasRecommendedScopes(account) && + !hasAlternateScopes(account), + ) && ( + + + One or more accounts are missing scopes required for notification + enrichment. Notification details may be incomplete. + + + + } + title="Missing notification scopes" + variant="warning" + /> + )} + + +
+ toggleSetting('showPills')} + tooltip={ + + Show notification metrics for: +
+ + + + linked issues + + + + reactions + + + + pull request reviews + + + + comments + + + + labels + + + + milestones + - -
-
- } - /> -
- )} - - toggleSetting('markAsDoneOnOpen')} - tooltip={ - - Mark as done feature is supported by GitHub Cloud and - GitHub Enterprise Server 3.13 or later. - - } - /> - - toggleSetting('markAsDoneOnUnsubscribe')} - tooltip={ - - Mark as done feature is supported by GitHub Cloud and - GitHub Enterprise Server 3.13 or later. - - } - /> - - {!hasMarkAsDoneAccount && ( - - Mark as done requires GitHub Cloud or GitHub Enterprise Server 3.13 or later. - - )} +
+
+ } + /> +
+ + + toggleSetting('markAsDoneOnOpen')} + tooltip="Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later." + /> + + toggleSetting('markAsDoneOnUnsubscribe')} + tooltip="Mark as done is supported by GitHub Cloud and GitHub Enterprise Server 3.13 or later." + /> + ) : ( GitHub-specific options require a GitHub account. )} - +
); } diff --git a/src/renderer/components/settings/SettingsGroupHeading.test.tsx b/src/renderer/components/settings/SettingsGroupHeading.test.tsx index 0a98eb95c..9174f0f19 100644 --- a/src/renderer/components/settings/SettingsGroupHeading.test.tsx +++ b/src/renderer/components/settings/SettingsGroupHeading.test.tsx @@ -7,7 +7,7 @@ import { SettingsGroupHeading } from './SettingsGroupHeading'; describe('renderer/components/settings/SettingsGroupHeading.tsx', () => { it('should render a group heading', async () => { const { container } = renderWithProviders( - Fetching, + Fetch, ); expect(container).toMatchSnapshot(); diff --git a/src/renderer/components/settings/SettingsGroupHeading.tsx b/src/renderer/components/settings/SettingsGroupHeading.tsx index 89f32340d..8687d7875 100644 --- a/src/renderer/components/settings/SettingsGroupHeading.tsx +++ b/src/renderer/components/settings/SettingsGroupHeading.tsx @@ -11,7 +11,10 @@ export function SettingsGroupHeading({ children, }: Readonly) { return ( - + // Primer's Stack zeroes its own padding, so this needs an important value. + // 1rem plus the preceding row's 8px padding keeps the same 24px separation + // that section headings get, without drawing a divider after the last row. + {children} diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index 56f4aa81b..af8a5cc25 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -19,33 +19,33 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { updateSettingSpy = vi.spyOn(useSettingsStore.getState(), 'updateSetting'); }); - it('should change the open links radio group', async () => { + it('should change the link focus choice', async () => { await act(async () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId('radio-openLinks-background')); + await userEvent.click(screen.getByTestId('choice-openLinks-background')); expect(updateSettingSpy).toHaveBeenCalledTimes(1); expect(updateSettingSpy).toHaveBeenCalledWith('openLinks', 'BACKGROUND'); }); it.each([ - ['checkbox-keyboardShortcut', 'keyboardShortcut'], - ['checkbox-openAtStartup', 'openAtStartup'], - ['checkbox-keepWindowOnBlur', 'keepWindowOnBlur'], - ] as const)('should toggle %s checkbox', async (testId, setting) => { + ['Enable global shortcut', 'keyboardShortcut'], + ['Launch at startup', 'openAtStartup'], + ['Keep window open when unfocused', 'keepWindowOnBlur'], + ] as const)('should toggle %s switch', async (label, setting) => { await act(async () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId(testId)); + await userEvent.click(screen.getByRole('button', { name: label })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); - describe('X11 backend checkbox', () => { + describe('X11 backend switch', () => { // `window.gitify` is rebuilt in a global `beforeEach`, so the mock has to // be read inside each test rather than captured at describe scope. const isLinuxMock = () => window.gitify.platform.isLinux as unknown as ReturnType; @@ -57,7 +57,7 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { renderWithProviders(); }); - expect(screen.queryByTestId('checkbox-useX11Backend')).not.toBeInTheDocument(); + expect(screen.queryByTestId('switch-useX11Backend')).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'GNOME extension installation' }), ).not.toBeInTheDocument(); @@ -70,7 +70,9 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { renderWithProviders(); }); - await userEvent.click(screen.getByTestId('checkbox-useX11Backend')); + await userEvent.click( + screen.getByRole('button', { name: 'Use X11 backend (restart required)' }), + ); expect(toggleSettingSpy).toHaveBeenCalledWith('useX11Backend'); }); @@ -119,7 +121,19 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { settings: { ...mockSettings, keyboardShortcut: false }, }); - expect(screen.queryByTestId('settings-shortcut-group')).not.toBeInTheDocument(); + expect(screen.getByTestId('reveal-shortcutRecorder')).toHaveAttribute('data-state', 'closed'); + }); + + it('shows the shortcut rules on the recorder row', async () => { + renderWithProviders(, { + settings: { ...mockSettings, keyboardShortcut: true }, + }); + + expect(screen.getByText('Global shortcut')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('tooltip-icon-tooltip-shortcut')); + + expect(screen.getByText('Shortcuts must include:')).toBeInTheDocument(); }); describe('recording global shortcut', () => { diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 984583666..fcf402a23 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -3,16 +3,12 @@ import { useEffect, useRef, useState } from 'react'; import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react'; import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; -import { APPLICATION } from '../../../shared/constants'; - import { DEFAULT_SETTINGS_STATE, useSettingsStore, useShortcutRegistrationStore, } from '../../stores'; -import { Checkbox } from '../fields/Checkbox'; -import { RadioGroup } from '../fields/RadioGroup'; import { SectionHeading } from '../primitives/SectionHeading'; import { type KeyboardAcceleratorShortcut, OpenPreference, toLink } from '../../types'; @@ -23,6 +19,11 @@ import { keyboardEventToAccelerator, MODIFIER_SEGMENTS, } from '../../utils/system/keyboardShortcut'; +import { SettingsChoice } from './controls/SettingsChoice'; +import { SettingsList } from './controls/SettingsList'; +import { SettingsReveal } from './controls/SettingsReveal'; +import { SettingsRow } from './controls/SettingsRow'; +import { SettingsToggle } from './controls/SettingsToggle'; const defaultSettings = DEFAULT_SETTINGS_STATE; @@ -124,13 +125,11 @@ export function SystemSettings() { System - - + { - updateSetting('openLinks', evt.target.value as OpenPreference); - }} + onChange={(value) => updateSetting('openLinks', value as OpenPreference)} options={[ { label: 'Foreground', value: OpenPreference.FOREGROUND }, { label: 'Background', value: OpenPreference.BACKGROUND }, @@ -149,8 +148,8 @@ export function SystemSettings() { current window. - This applies to every link {APPLICATION.NAME} opens, including notification links - opened in the browser. + This applies to every link Gitify opens, including notification links opened in the + browser. } @@ -174,141 +173,143 @@ export function SystemSettings() { /> )} - - toggleSetting('keyboardShortcut')} - tooltip={ - - Global keyboard shortcut to show or hide {APPLICATION.NAME}. - Shortcuts must include: -
- - - • - - Primary modifier:{' '} - - {formatAcceleratorForDisplay('CommandOrControl', isMac)} - - - - - • - - Optional modifiers:{' '} - - {formatAcceleratorForDisplay('Shift', isMac)} + { + if (keyboardShortcut) { + setRecordingShortcut(false); + setLiveModifierAccelerator(''); + } + toggleSetting('keyboardShortcut'); + }} + tooltip="Show or hide Gitify from anywhere with a keyboard shortcut." + /> + + +
+ + Shortcuts must include: +
+ + + • + + Primary modifier:{' '} + + {formatAcceleratorForDisplay('CommandOrControl', isMac)} + - ,{' '} - - {formatAcceleratorForDisplay('Alt', isMac)} + + + • + + Optional modifiers:{' '} + + {formatAcceleratorForDisplay('Shift', isMac)} + + ,{' '} + + {formatAcceleratorForDisplay('Alt', isMac)} + - - - - • - A non-modifier key (letter, number, etc.) + + + • + A non-modifier key (letter, number, etc.) + - -
- - } - /> - - {keyboardShortcut && ( - - { - clearShortcutRegistrationError(); - setLiveModifierAccelerator(''); - setRecordingShortcut(true); - }} - size="small" - unsafeDisableTooltip={true} - /> - - { - clearShortcutRegistrationError(); - updateSetting('openGitifyShortcut', defaultSettings.openGitifyShortcut); - }} - size="small" - unsafeDisableTooltip={true} - variant="danger" - /> - - )} - - - + + } + > + + { + clearShortcutRegistrationError(); + setLiveModifierAccelerator(''); + setRecordingShortcut(true); + }} + size="small" + unsafeDisableTooltip={true} + /> + + { + clearShortcutRegistrationError(); + updateSetting('openGitifyShortcut', defaultSettings.openGitifyShortcut); + }} + size="small" + unsafeDisableTooltip={true} + variant="danger" + /> + +
+
+
+ + toggleSetting('keepWindowOnBlur')} - tooltip={Keep the {APPLICATION.NAME} window open when you click outside it.} + tooltip="Keep the Gitify window open when you click outside it." /> - toggleSetting('openAtStartup')} - tooltip={Launch {APPLICATION.NAME} automatically at startup.} + tooltip="Launch Gitify automatically at startup." visible={!isLinux} /> - toggleSetting('useX11Backend')} - tooltip={ - - Run {APPLICATION.NAME} under X11/XWayland so the window can open next to the tray - icon. Wayland placement still depends on your compositor and its extensions. This - disables Vulkan and may soften text on fractional-scaling displays. Takes effect after - restarting {APPLICATION.NAME}. - - } + tooltip="Run Gitify under X11/XWayland so the window can open next to the tray icon. Takes effect after restarting Gitify." visible={isLinux} /> - - {isLinux && ( - - - On GNOME Wayland, install the {APPLICATION.NAME} extension to open the window next to - its tray icon. Install and manage it through GNOME's extension tools. - - - - )} -
+ + + {isLinux && ( + + + On GNOME Wayland, install the Gitify extension to open the window next to its tray icon. + Install and manage it through GNOME's extension tools. + + + + )} ); } diff --git a/src/renderer/components/settings/TraySettings.test.tsx b/src/renderer/components/settings/TraySettings.test.tsx index 53fa38055..f5558fcd3 100644 --- a/src/renderer/components/settings/TraySettings.test.tsx +++ b/src/renderer/components/settings/TraySettings.test.tsx @@ -19,10 +19,10 @@ describe('renderer/components/settings/TraySettings.tsx', () => { }); it.each([ - ['checkbox-showNotificationsCountInTray', 'showNotificationsCountInTray'], - ['checkbox-useUnreadActiveIcon', 'useUnreadActiveIcon'], - ] as const)('should toggle %s checkbox', async (testId, setting) => { - await userEvent.click(screen.getByTestId(testId)); + ['Show notification count', 'showNotificationsCountInTray'], + ['Highlight unread notifications', 'useUnreadActiveIcon'], + ] as const)('should toggle %s switch', async (label, setting) => { + await userEvent.click(screen.getByRole('button', { name: label })); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith(setting); @@ -30,7 +30,7 @@ describe('renderer/components/settings/TraySettings.tsx', () => { it.each(['auto', 'light', 'dark'] as const)( 'selects the %s icon appearance', async (appearance) => { - await userEvent.click(screen.getByTestId(`radio-trayIconAppearance-${appearance}`)); + await userEvent.click(screen.getByTestId(`choice-trayIconAppearance-${appearance}`)); expect(useSettingsStore.getState().trayIconAppearance).toBe(appearance); }, ); diff --git a/src/renderer/components/settings/TraySettings.tsx b/src/renderer/components/settings/TraySettings.tsx index 2e1028791..11f4ea425 100644 --- a/src/renderer/components/settings/TraySettings.tsx +++ b/src/renderer/components/settings/TraySettings.tsx @@ -6,10 +6,12 @@ import { isTrayIconAppearance } from '../../../shared/events'; import { useSettingsStore } from '../../stores'; -import { Checkbox } from '../fields/Checkbox'; -import { RadioGroup } from '../fields/RadioGroup'; import { SectionHeading } from '../primitives/SectionHeading'; +import { SettingsChoice } from './controls/SettingsChoice'; +import { SettingsList } from './controls/SettingsList'; +import { SettingsToggle } from './controls/SettingsToggle'; + export function TraySettings() { const updateSetting = useSettingsStore((s) => s.updateSetting); @@ -27,62 +29,50 @@ export function TraySettings() { Tray - - + toggleSetting('showNotificationsCountInTray')} - tooltip={ - - Show the unread notification count next to the tray icon. Useful for a quick glance at - unread activity. - - } + tooltip="Show the unread notification count next to the tray icon. Useful for a quick glance at unread activity." visible={window.gitify.platform.isMacOS()} /> - toggleSetting('useUnreadActiveIcon')} - tooltip={ - - - Turn the {APPLICATION.NAME} logo green while there are unread notifications. - - - } + tooltip={`Turn the ${APPLICATION.NAME} logo green while there are unread notifications.`} /> - { + if (isTrayIconAppearance(value)) { + updateSetting('trayIconAppearance', value); + } + }} options={[ { label: 'Automatic', value: 'auto' }, { label: 'Light', value: 'light' }, { label: 'Dark', value: 'dark' }, ]} - onChange={(event) => { - const value = event.target.value; - if (isTrayIconAppearance(value)) { - updateSetting('trayIconAppearance', value); - } - }} tooltip={ Pick a light icon for dark panels or a dark icon for light panels. - Choose Automatic to follow the tray appearance on macOS and Windows; Linux uses - light because panel detection is unreliable. + Automatic follows the tray appearance on macOS and Windows; Linux uses light because + panel detection is unreliable. This setting is independent of the {APPLICATION.NAME} app theme. } + value={trayIconAppearance} /> - + ); } diff --git a/src/renderer/components/settings/__snapshots__/SettingsGroupHeading.test.tsx.snap b/src/renderer/components/settings/__snapshots__/SettingsGroupHeading.test.tsx.snap index 8d477a5ca..f89c8bb68 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsGroupHeading.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsGroupHeading.test.tsx.snap @@ -3,7 +3,7 @@ exports[`renderer/components/settings/SettingsGroupHeading.tsx > should render a group heading 1`] = `
should render a class="text-sm font-semibold text-gitify-font prc-Text-Text-9mHv3" data-component="Text" > - Fetching + Fetch
diff --git a/src/renderer/components/settings/controls/SettingsChoice.test.tsx b/src/renderer/components/settings/controls/SettingsChoice.test.tsx new file mode 100644 index 000000000..215644725 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsChoice.test.tsx @@ -0,0 +1,129 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderWithProviders } from '../../../__helpers__/test-utils'; + +import { SettingsChoice } from './SettingsChoice'; + +describe('renderer/components/settings/controls/SettingsChoice.tsx', () => { + it('renders a labelled segmented choice', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Group by')).toBeInTheDocument(); + expect(screen.getByText('Choose how notifications are grouped.')).toBeInTheDocument(); + expect(screen.getByTestId('choice-groupBy-repository')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('choice-groupBy-date')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onChange when a segment is selected', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.click(screen.getByTestId('choice-groupBy-date')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('date'); + }); + + it('cycles to the next segment when the label is clicked', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.click(screen.getByText('Group by')); + + expect(onChange).toHaveBeenCalledWith('date'); + }); + + it('renders a select variant', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Theme' }), 'dark'); + + expect(onChange).toHaveBeenCalledWith('dark'); + }); + + it('focuses the select when the label is clicked', async () => { + renderWithProviders( + , + ); + + await userEvent.click(screen.getByText('Theme')); + + expect(screen.getByRole('combobox', { name: 'Theme' })).toHaveFocus(); + }); + + it('renders nothing when hidden', () => { + renderWithProviders( + , + ); + + expect(screen.queryByText('Group by')).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/settings/controls/SettingsChoice.tsx b/src/renderer/components/settings/controls/SettingsChoice.tsx new file mode 100644 index 000000000..a3f8ed581 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsChoice.tsx @@ -0,0 +1,155 @@ +import { type ReactNode, useId, useRef } from 'react'; + +import { SegmentedControl, Select } from '@primer/react'; + +import { cn } from 'cn'; + +import { Tooltip } from '../../fields/Tooltip'; + +export interface SettingsChoiceOption { + label: string; + value: string; +} + +export interface SettingsChoiceProps { + name: string; + label: string; + caption?: ReactNode; + tooltip?: ReactNode; + value: string; + options?: SettingsChoiceOption[]; + presentation?: 'segmented' | 'select'; + selectChildren?: ReactNode; + disabled?: boolean; + visible?: boolean; + onChange: (value: string) => void; +} + +export function SettingsChoice({ + name, + label, + caption, + tooltip, + value, + options = [], + presentation = 'segmented', + selectChildren, + disabled = false, + visible = true, + onChange, +}: Readonly) { + const id = useId(); + const labelId = `${name}-${id}-label`; + const captionId = `${name}-${id}-caption`; + const selectId = `${name}-${id}-select`; + const selectRef = useRef(null); + + if (!visible) { + return null; + } + + const focusControl = () => { + if (presentation === 'select') { + const select = selectRef.current; + + if (!select) { + return; + } + + select.focus(); + + if (typeof select.showPicker === 'function') { + try { + select.showPicker(); + } catch { + // Focus is the fallback when the browser cannot open the picker. + } + } + + return; + } + + const currentIndex = options.findIndex((option) => option.value === value); + const nextOption = options[(currentIndex + 1) % options.length]; + + if (nextOption) { + onChange(nextOption.value); + } + }; + + const control = + presentation === 'select' ? ( + + ) : ( + { + const option = options[index]; + if (option) { + onChange(option.value); + } + }} + > + {options.map((option) => ( + + {option.label} + + ))} + + ); + + return ( +
+
+
+ {/* Primer's unlayered `button { font: inherit }` outranks Tailwind's + utilities, so the label typography is marked important to match + the row labels. */} + + {tooltip && } +
+
{control}
+
+ + {caption && ( +
+ {caption} +
+ )} +
+ ); +} diff --git a/src/renderer/components/settings/controls/SettingsList.test.tsx b/src/renderer/components/settings/controls/SettingsList.test.tsx new file mode 100644 index 000000000..e6d21354a --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsList.test.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../../__helpers__/test-utils'; + +import { SettingsList } from './SettingsList'; + +describe('renderer/components/settings/controls/SettingsList.tsx', () => { + it('renders its children in a divided list', () => { + renderWithProviders( + +
First setting
+
Second setting
+
, + ); + + expect(screen.getByText('First setting')).toBeInTheDocument(); + expect(screen.getByText('Second setting')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/settings/controls/SettingsList.tsx b/src/renderer/components/settings/controls/SettingsList.tsx new file mode 100644 index 000000000..8ff43b4f5 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsList.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; + +export interface SettingsListProps { + children: ReactNode; +} + +export function SettingsList({ children }: Readonly) { + return
{children}
; +} diff --git a/src/renderer/components/settings/controls/SettingsReveal.test.tsx b/src/renderer/components/settings/controls/SettingsReveal.test.tsx new file mode 100644 index 000000000..54c22983f --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsReveal.test.tsx @@ -0,0 +1,32 @@ +import { screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../../__helpers__/test-utils'; + +import { SettingsReveal } from './SettingsReveal'; + +describe('renderer/components/settings/controls/SettingsReveal.tsx', () => { + it('exposes an open state', () => { + renderWithProviders( + +
Details
+
, + ); + + expect(screen.getByTestId('reveal-details')).toHaveAttribute('data-state', 'open'); + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + it('hides and inerts collapsed content', () => { + renderWithProviders( + +
Details
+
, + ); + + const reveal = screen.getByTestId('reveal-details'); + + expect(reveal).toHaveAttribute('data-state', 'closed'); + expect(reveal).toHaveAttribute('aria-hidden', 'true'); + expect(reveal).toHaveAttribute('inert'); + }); +}); diff --git a/src/renderer/components/settings/controls/SettingsReveal.tsx b/src/renderer/components/settings/controls/SettingsReveal.tsx new file mode 100644 index 000000000..8c94550b1 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsReveal.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; + +import { cn } from 'cn'; + +export interface SettingsRevealProps { + name?: string; + open: boolean; + children: ReactNode; +} + +export function SettingsReveal({ name, open, children }: Readonly) { + return ( +
+
{children}
+
+ ); +} diff --git a/src/renderer/components/settings/controls/SettingsRow.test.tsx b/src/renderer/components/settings/controls/SettingsRow.test.tsx new file mode 100644 index 000000000..d423fda41 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsRow.test.tsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../../__helpers__/test-utils'; + +import { SettingsRow } from './SettingsRow'; + +describe('renderer/components/settings/controls/SettingsRow.tsx', () => { + it('renders a label, caption, and control', () => { + renderWithProviders( + + + , + ); + + expect(screen.getByText('Fetch interval')).toBeInTheDocument(); + expect(screen.getByText('Check for new notifications at this interval.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Control' })).toBeInTheDocument(); + }); + + it('renders a visible disabled reason', () => { + renderWithProviders( + + + , + ); + + expect( + screen.getByText('Requires GitHub Enterprise Server 3.13 or later.'), + ).toBeInTheDocument(); + }); + + it('renders nothing when hidden', () => { + renderWithProviders( + + + , + ); + + expect(screen.queryByText('Fetch interval')).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/settings/controls/SettingsRow.tsx b/src/renderer/components/settings/controls/SettingsRow.tsx new file mode 100644 index 000000000..af4d02139 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsRow.tsx @@ -0,0 +1,63 @@ +import { type ReactNode, useId } from 'react'; + +import { cn } from 'cn'; + +import { Tooltip } from '../../fields/Tooltip'; + +export interface SettingsRowProps { + name: string; + label: string; + caption?: ReactNode; + tooltip?: ReactNode; + children: ReactNode; + disabled?: boolean; + disabledReason?: ReactNode; + visible?: boolean; +} + +export function SettingsRow({ + name, + label, + caption, + tooltip, + children, + disabled = false, + disabledReason, + visible = true, +}: Readonly) { + const id = useId(); + const labelId = `${name}-${id}-label`; + const captionId = `${name}-${id}-caption`; + + if (!visible) { + return null; + } + + return ( +
+
+
+ + {label} + + {tooltip && } +
+
{children}
+
+ + {caption && ( +
+ {caption} +
+ )} + + {disabled && disabledReason && ( +
{disabledReason}
+ )} +
+ ); +} diff --git a/src/renderer/components/settings/controls/SettingsToggle.test.tsx b/src/renderer/components/settings/controls/SettingsToggle.test.tsx new file mode 100644 index 000000000..b43997d55 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsToggle.test.tsx @@ -0,0 +1,98 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderWithProviders } from '../../../__helpers__/test-utils'; + +import { SettingsToggle } from './SettingsToggle'; + +describe('renderer/components/settings/controls/SettingsToggle.tsx', () => { + it('renders a labelled switch with a caption', () => { + renderWithProviders( + , + ); + + const toggle = screen.getByRole('button', { name: 'Show notifications' }); + + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(toggle).toHaveAccessibleDescription(/Shows new notifications\./); + expect(screen.getByTestId('switch-showNotifications')).toContainElement(toggle); + }); + + it('calls onChange with the next value', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Show notifications' })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('toggles from the label', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.click(screen.getByText('Show notifications')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('does not toggle when disabled', async () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Show notifications' })); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Show notifications' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('renders nothing when hidden', () => { + renderWithProviders( + , + ); + + expect(screen.queryByTestId('switch-showNotifications')).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/settings/controls/SettingsToggle.tsx b/src/renderer/components/settings/controls/SettingsToggle.tsx new file mode 100644 index 000000000..3f2d54414 --- /dev/null +++ b/src/renderer/components/settings/controls/SettingsToggle.tsx @@ -0,0 +1,77 @@ +import { type ReactNode, useId } from 'react'; + +import { ToggleSwitch } from '@primer/react'; + +import { cn } from 'cn'; + +import { Tooltip } from '../../fields/Tooltip'; + +export interface SettingsToggleProps { + name: string; + label: string; + caption?: ReactNode; + tooltip?: ReactNode; + checked: boolean; + disabled?: boolean; + visible?: boolean; + onChange: (checked: boolean) => void; +} + +export function SettingsToggle({ + name, + label, + caption, + tooltip, + checked, + disabled = false, + visible = true, + onChange, +}: Readonly) { + const id = useId(); + const labelId = `${name}-${id}-label`; + const captionId = `${name}-${id}-caption`; + + if (!visible) { + return null; + } + + return ( +
+
+
+ {/* Primer's unlayered `button { font: inherit }` outranks Tailwind's + utilities, so the label typography is marked important to match + the other row labels. */} + + {tooltip && } +
+ + {caption && ( +
+ {caption} +
+ )} +
+ + +
+ ); +} diff --git a/src/renderer/routes/Settings.test.tsx b/src/renderer/routes/Settings.test.tsx index 7b2096fd5..04d9face6 100644 --- a/src/renderer/routes/Settings.test.tsx +++ b/src/renderer/routes/Settings.test.tsx @@ -43,19 +43,17 @@ describe('renderer/routes/Settings.tsx', () => { const alerts = section('Alerts'); const system = section('System'); - expect(within(inbox).getByTestId('checkbox-showPills')).toBeInTheDocument(); - expect(within(inbox).getByTestId('checkbox-wrapNotificationTitle')).toBeInTheDocument(); - expect(within(inbox).queryByTestId('checkbox-showNotifications')).not.toBeInTheDocument(); + expect(within(inbox).getByTestId('switch-showPills')).toBeInTheDocument(); + expect(within(inbox).getByTestId('switch-wrapNotificationTitle')).toBeInTheDocument(); + expect(within(inbox).queryByTestId('switch-showNotifications')).not.toBeInTheDocument(); expect( - within(appearance).queryByTestId('checkbox-wrapNotificationTitle'), - ).not.toBeInTheDocument(); - expect(within(alerts).getByTestId('checkbox-showNotifications')).toBeInTheDocument(); - expect(within(alerts).getByTestId('checkbox-showUpdateNotifications')).toBeInTheDocument(); - expect(within(alerts).queryByTestId('checkbox-showPills')).not.toBeInTheDocument(); - expect(within(system).queryByTestId('checkbox-showNotifications')).not.toBeInTheDocument(); - expect( - within(system).queryByTestId('checkbox-showUpdateNotifications'), + within(appearance).queryByTestId('switch-wrapNotificationTitle'), ).not.toBeInTheDocument(); + expect(within(alerts).getByTestId('switch-showNotifications')).toBeInTheDocument(); + expect(within(alerts).getByTestId('switch-showUpdateNotifications')).toBeInTheDocument(); + expect(within(alerts).queryByTestId('switch-showPills')).not.toBeInTheDocument(); + expect(within(system).queryByTestId('switch-showNotifications')).not.toBeInTheDocument(); + expect(within(system).queryByTestId('switch-showUpdateNotifications')).not.toBeInTheDocument(); }); it('should go back by pressing the icon', async () => { diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png index 9293aeb4f..6e95d87c6 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png index 33c8f6f7c..53f7b6655 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png index 66c34f015..4b120d09a 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png index 4961d2a84..c95515e22 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_dimmed-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png index 40244cd26..39d566381 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-dark_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png index edab70874..947aad1c4 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png index 2535b6f04..6390cd7e2 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light-glass-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png index 6aa6807eb..bc8d1ae6c 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_colorblind-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png index c52a971b2..fd60fcafe 100644 Binary files a/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png and b/src/renderer/routes/__screenshots__/routes.visual.test.tsx/settings-light_tritanopia-classic-chromium-linux.png differ diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index 493062459..43c6ec0d8 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -88,7 +88,7 @@ exports[`renderer/routes/Filters.tsx > General > should render itself & its chil
should render itself & its children 1`]
should render itself & its children 1`]
-
- - - - - - -
-
- - - - - - -
-
- - - -
-
- - +
+
- + +
+ Classic +
+
+ + +
  • + +
  • +
    +
    +
    +
    +
    + +
    +
    + + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    + + + +
    +
    +
    +
    + + Zoom +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    -
    +
    @@ -654,18 +771,9 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`]
    -
    +
    should render itself & its children 1`] class="text-sm font-semibold text-gitify-font prc-Text-Text-9mHv3" data-component="Text" > - Fetching + Fetch
    - - -
    - + Fetch interval + + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    - + + + +
    + +
    + +
    + +
    +
    +
    +
    + + +
    +
    + +
    + +
    +
    - - - -
    -
    - - - -
    -
    should render itself & its children 1`]
    - -
    - -
    - -
    +
    - Date - + +
    + + + +
    - -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - -
    +
    + +
    + + + +
    +
    +
    - Delay notification removal - - + +
    +
    +
    + +
    + + + +
    +
    +
    - - +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    should render itself & its children 1`]
    - - - -
    -
    - - - -
    -
    -
    - - One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete. - - + + +
    +
    +
    - Visit - + +
    + + - - to review. - +
    -
    -
    - - - + +
    +
    +
    + +
    + + + +
    +
    +
    +
    +
    +
    +
    +

    + Missing notification scopes +

    +
    +
    + + One or more accounts are missing scopes required for notification enrichment. Notification details may be incomplete. + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + + + + +
    -
    - - - -
    -
    - - - -
    @@ -1707,248 +2870,517 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`]
    - - - -
    -
    +
    + + +
    +
    - -
    +
    + +
    +
    +
    + +
    + + + -
    -
    +
    +
    + +
    +
    +
    +
    - -
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + + + +
    +
    -
    -
    -
    -
    -
    -
    - - - +
    + +
    - +
    + + + + +
    @@ -2069,236 +3542,414 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`]
    - - - + +
    +
    +
    -
    - - -
    -
    - - - + +
    -
    - - + + +
    - -
    +
    - -
    +
    - Dark - +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    - @@ -2346,195 +3997,453 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] System - -
    +
    +
    +
    +
    +
    + + +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    -
    - - + + +
    - - -
    - -
    -
    -
    - - + + On + + + Off + +
    + +
    +
    +
    +
    +
    +
    +
    + + Global shortcut + + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + +
    +
    +
    +
    -
    -
    -
    -
    - - - -
    -
    - - - + +
    @@ -2779,7 +4753,7 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] data-wrap="nowrap" >