diff --git a/biome.json b/biome.json index 077e2a520..f2383d9b2 100644 --- a/biome.json +++ b/biome.json @@ -58,6 +58,7 @@ "useDefaultSwitchClause": "error", "noParameterAssign": "error", "useAsConstAssertion": "error", + "useBlockStatements": "error", "useDefaultParameterLast": "error", "useEnumInitializers": "error", "useSelfClosingElements": "error", diff --git a/package.json b/package.json index de4563eb4..b49995cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitify", - "version": "6.6.0", + "version": "6.8.0", "description": "GitHub notifications on your menu bar.", "main": "build/main.js", "scripts": { @@ -78,7 +78,7 @@ "@discordapp/twemoji": "16.0.1", "@electron/notarize": "3.1.0", "@primer/css": "22.0.2", - "@primer/octicons-react": "19.16.0", + "@primer/octicons-react": "19.18.0", "@primer/primitives": "11.1.0", "@primer/react": "36.27.0", "@tailwindcss/postcss": "4.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f229aa89..089bb26eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: specifier: 22.0.2 version: 22.0.2(@primer/primitives@11.1.0) '@primer/octicons-react': - specifier: 19.16.0 - version: 19.16.0(react@19.1.1) + specifier: 19.18.0 + version: 19.18.0(react@19.1.1) '@primer/primitives': specifier: 11.1.0 version: 11.1.0 @@ -771,8 +771,8 @@ packages: '@primer/live-region-element@0.7.1': resolution: {integrity: sha512-9uQCeBCb3wefz3kJNSo+PECc7T7TNB3k22JUdHY08Zlv9bd1rtsQgpazM5umcbZQrACzGbgufAfdbhGUBXI3jA==} - '@primer/octicons-react@19.16.0': - resolution: {integrity: sha512-IbM5Qn2uOpHia2oQ9WtR6ZsnsiQk7Otc4Y7YfE4Q5023co24iGlu+xz2pOUxd5iSACM4qLZOPyWiwsL8P9Inkw==} + '@primer/octicons-react@19.18.0': + resolution: {integrity: sha512-nLFlLmWfz3McbTiOUKVO+iwB15ALYQC9rHeP8K3qM1pyJ8svGaPjGR72BQSEM8ThyQUUodq/Re1n94tO5NNhzQ==} engines: {node: '>=8'} peerDependencies: react: '>=16.3' @@ -5431,7 +5431,7 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.2.1 - '@primer/octicons-react@19.16.0(react@19.1.1)': + '@primer/octicons-react@19.18.0(react@19.1.1)': dependencies: react: 19.1.1 @@ -5450,7 +5450,7 @@ snapshots: '@oddbird/popover-polyfill': 0.3.8 '@primer/behaviors': 1.8.0 '@primer/live-region-element': 0.7.1 - '@primer/octicons-react': 19.16.0(react@19.1.1) + '@primer/octicons-react': 19.18.0(react@19.1.1) '@primer/primitives': 7.17.1 '@styled-system/css': 5.1.5 '@styled-system/props': 5.1.5 diff --git a/sonar-project.properties b/sonar-project.properties index 2c85e2b1a..ccb2c9d7e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,7 +4,7 @@ # ===================================================== sonar.projectKey=gitify-app_gitify sonar.organization=gitify-app -sonar.projectVersion=v6.6.0 +sonar.projectVersion=v6.8.0 sonar.projectDescription=GitHub notifications on your menu bar. diff --git a/src/main/first-run.ts b/src/main/first-run.ts index c5fe6daeb..224d79c8b 100644 --- a/src/main/first-run.ts +++ b/src/main/first-run.ts @@ -17,10 +17,14 @@ export async function onFirstRunMaybe() { * Ask user if the app should be moved to the applications folder (masOS). */ async function promptMoveToApplicationsFolder() { - if (!isMacOS()) return; + if (!isMacOS()) { + return; + } const isDevMode = !!process.defaultApp; - if (isDevMode || app.isInApplicationsFolder()) return; + if (isDevMode || app.isInApplicationsFolder()) { + return; + } const { response } = await dialog.showMessageBox({ type: 'question', diff --git a/src/main/index.ts b/src/main/index.ts index 5c3de8ec5..b78e26f8a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; import { app, diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 3ff570ebd..7c460aa09 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -22,7 +22,9 @@ const listeners: ListenerMap = {}; jest.mock('electron-updater', () => ({ autoUpdater: { on: jest.fn((event: string, cb: Listener) => { - if (!listeners[event]) listeners[event] = []; + if (!listeners[event]) { + listeners[event] = []; + } listeners[event].push(cb); return this; }), @@ -59,16 +61,16 @@ describe('main/updater.ts', () => { public setNoUpdateAvailableMenuVisibility = jest.fn(); public setUpdateAvailableMenuVisibility = jest.fn(); public setUpdateReadyForInstallMenuVisibility = jest.fn(); - constructor(mb: Menubar) { - super(mb); - } } + let menuBuilder: TestMenuBuilder; let updater: AppUpdater; beforeEach(() => { jest.clearAllMocks(); - for (const k of Object.keys(listeners)) delete listeners[k]; + for (const k of Object.keys(listeners)) { + delete listeners[k]; + } menubar = { app: { diff --git a/src/main/updater.ts b/src/main/updater.ts index 60dac989a..fd37bea02 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -140,7 +140,9 @@ export default class AppUpdater { }; dialog.showMessageBox(dialogOpts).then((returnValue) => { - if (returnValue.response === 0) autoUpdater.quitAndInstall(); + if (returnValue.response === 0) { + autoUpdater.quitAndInstall(); + } }); } } diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 38e52824b..2eb95b8c6 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -67,3 +67,5 @@ global.CSS = { return false; }), }; + +window.HTMLMediaElement.prototype.play = jest.fn(); diff --git a/src/renderer/__mocks__/partial-mocks.ts b/src/renderer/__mocks__/partial-mocks.ts index b98d1890e..d40b01e73 100644 --- a/src/renderer/__mocks__/partial-mocks.ts +++ b/src/renderer/__mocks__/partial-mocks.ts @@ -1,10 +1,11 @@ import { Constants } from '../constants'; import type { Hostname, Link } from '../types'; -import type { Notification, Subject, User } from '../typesGitHub'; +import type { Notification, Repository, Subject, User } from '../typesGitHub'; import { mockGitifyUser, mockToken } from './state-mocks'; export function partialMockNotification( subject: Partial, + repository?: Partial, ): Notification { const mockNotification: Partial = { account: { @@ -16,6 +17,7 @@ export function partialMockNotification( hasRequiredScopes: true, }, subject: subject as Subject, + repository: repository as Repository, }; return mockNotification as Notification; diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 377000f3f..a03520d44 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -109,10 +109,8 @@ const mockSystemSettings: SystemSettingsState = { const mockFilters: FilterSettingsState = { filterUserTypes: [], - filterIncludeHandles: [], - filterExcludeHandles: [], - filterIncludeOrganizations: [], - filterExcludeOrganizations: [], + filterIncludeSearchTokens: [], + filterExcludeSearchTokens: [], filterSubjectTypes: [], filterStates: [], filterReasons: [], diff --git a/src/renderer/components/AllRead.test.tsx b/src/renderer/components/AllRead.test.tsx index 10cc58cb9..743e4d6cf 100644 --- a/src/renderer/components/AllRead.test.tsx +++ b/src/renderer/components/AllRead.test.tsx @@ -1,5 +1,4 @@ import { act, render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockSettings } from '../__mocks__/state-mocks'; import { ensureStableEmojis } from '../__mocks__/utils'; @@ -44,9 +43,7 @@ describe('renderer/components/AllRead.tsx', () => { }, }} > - - - + , ); }); diff --git a/src/renderer/components/avatars/AvatarWithFallback.test.tsx b/src/renderer/components/avatars/AvatarWithFallback.test.tsx index b94762e47..e358c048a 100644 --- a/src/renderer/components/avatars/AvatarWithFallback.test.tsx +++ b/src/renderer/components/avatars/AvatarWithFallback.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { type Link, Size } from '../../types'; import { @@ -47,8 +47,8 @@ describe('renderer/components/avatars/AvatarWithFallback.tsx', () => { // Find the avatar element by its alt text const avatar = screen.getByAltText('gitify-app') as HTMLImageElement; - // Simulate an error event on the image element - avatar.dispatchEvent(new Event('error')); + // Simulate image load error (wrapped in act via fireEvent) + fireEvent.error(avatar); expect(screen.getByTestId('avatar')).toMatchSnapshot(); }); @@ -59,8 +59,8 @@ describe('renderer/components/avatars/AvatarWithFallback.tsx', () => { // Find the avatar element by its alt text const avatar = screen.getByAltText('gitify-app') as HTMLImageElement; - // Simulate an error event on the image element - avatar.dispatchEvent(new Event('error')); + // Simulate image load error (wrapped in act via fireEvent) + fireEvent.error(avatar); expect(screen.getByTestId('avatar')).toMatchSnapshot(); }); diff --git a/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap b/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap index d7a5d4110..d02e07b1d 100644 --- a/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap +++ b/src/renderer/components/avatars/__snapshots__/AvatarWithFallback.test.tsx.snap @@ -273,16 +273,22 @@ exports[`renderer/components/avatars/AvatarWithFallback.tsx renders the fallback data-testid="avatar" data-wrap="nowrap" > - + > + +
- + > + +
{ notifications: mockAccountNotifications, }} > - - - + , ); @@ -88,15 +85,13 @@ describe('renderer/components/filters/FilterSection.tsx', () => { updateFilter, }} > - - - + , ); }); @@ -123,15 +118,13 @@ describe('renderer/components/filters/FilterSection.tsx', () => { updateFilter, }} > - - - + , ); }); diff --git a/src/renderer/components/filters/FilterSection.tsx b/src/renderer/components/filters/FilterSection.tsx index 47dbf8517..9fc98e521 100644 --- a/src/renderer/components/filters/FilterSection.tsx +++ b/src/renderer/components/filters/FilterSection.tsx @@ -7,7 +7,6 @@ import { AppContext } from '../../context/App'; import type { FilterSettingsState, FilterValue } from '../../types'; import type { Filter } from '../../utils/notifications/filters'; import { Checkbox } from '../fields/Checkbox'; -import { Tooltip } from '../fields/Tooltip'; import { Title } from '../primitives/Title'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; @@ -34,22 +33,21 @@ export const FilterSection = ({ return (
- - {title} - {tooltip && ( - - {tooltip} - {filter.requiresDetailsNotifications && ( - - )} - - } - /> - )} - + + {tooltip} + {filter.requiresDetailsNotifications && ( + <RequiresDetailedNotificationWarning /> + )} + </> + ) + } + > + {title} + { - beforeEach(() => { - mockUpdateFilter.mockReset(); - }); - - it('should render itself & its children', () => { - const props = { - updateFilter: mockUpdateFilter, - settings: mockSettings, - }; - - render( - - - , - ); - - expect(screen.getByText('Organizations')).toBeInTheDocument(); - expect(screen.getByText('Include:')).toBeInTheDocument(); - expect(screen.getByText('Exclude:')).toBeInTheDocument(); - }); - - describe('Include organizations', () => { - it('should handle organization includes', async () => { - const props = { - updateFilter: mockUpdateFilter, - settings: mockSettings, - }; - - render( - - - , - ); - - await userEvent.type( - screen.getByTitle('Include organizations'), - 'microsoft{enter}', - ); - - expect(mockUpdateFilter).toHaveBeenCalledWith( - 'filterIncludeOrganizations', - 'microsoft', - true, - ); - }); - - it('should not allow duplicate include organizations', async () => { - const props = { - updateFilter: mockUpdateFilter, - settings: { - ...mockSettings, - filterIncludeOrganizations: ['microsoft'], - } as SettingsState, - }; - - render( - - - , - ); - - await userEvent.type( - screen.getByTitle('Include organizations'), - 'microsoft{enter}', - ); - - expect(mockUpdateFilter).toHaveBeenCalledTimes(0); - }); - }); - - describe('Exclude organizations', () => { - it('should handle organization excludes', async () => { - const props = { - updateFilter: mockUpdateFilter, - settings: mockSettings, - }; - - render( - - - , - ); - - await userEvent.type( - screen.getByTitle('Exclude organizations'), - 'github{enter}', - ); - - expect(mockUpdateFilter).toHaveBeenCalledWith( - 'filterExcludeOrganizations', - 'github', - true, - ); - }); - - it('should not allow duplicate exclude organizations', async () => { - const props = { - updateFilter: mockUpdateFilter, - settings: { - ...mockSettings, - filterExcludeOrganizations: ['github'], - } as SettingsState, - }; - - render( - - - , - ); - - await userEvent.type( - screen.getByTitle('Exclude organizations'), - 'github{enter}', - ); - - expect(mockUpdateFilter).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/src/renderer/components/filters/OrganizationFilter.tsx b/src/renderer/components/filters/OrganizationFilter.tsx deleted file mode 100644 index 0bea7ab21..000000000 --- a/src/renderer/components/filters/OrganizationFilter.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { type FC, useContext, useEffect, useState } from 'react'; - -import { - CheckCircleFillIcon, - NoEntryFillIcon, - OrganizationIcon, -} from '@primer/octicons-react'; -import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; - -import { AppContext } from '../../context/App'; -import { IconColor, type Organization } from '../../types'; -import { - hasExcludeOrganizationFilters, - hasIncludeOrganizationFilters, -} from '../../utils/notifications/filters/organizations'; -import { Tooltip } from '../fields/Tooltip'; -import { Title } from '../primitives/Title'; - -type InputToken = { - id: number; - text: string; -}; - -const tokenEvents = ['Enter', 'Tab', ' ', ',']; - -export const OrganizationFilter: FC = () => { - const { updateFilter, settings } = useContext(AppContext); - - // biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on organization filter changes - useEffect(() => { - if (!hasIncludeOrganizationFilters(settings)) { - setIncludeOrganizations([]); - } - - if (!hasExcludeOrganizationFilters(settings)) { - setExcludeOrganizations([]); - } - }, [ - settings.filterIncludeOrganizations, - settings.filterExcludeOrganizations, - ]); - - const mapValuesToTokens = (values: string[]): InputToken[] => { - return values.map((value, index) => ({ - id: index, - text: value, - })); - }; - - const [includeOrganizations, setIncludeOrganizations] = useState< - InputToken[] - >(mapValuesToTokens(settings.filterIncludeOrganizations)); - - const addIncludeOrganizationsToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const value = (event.target as HTMLInputElement).value.trim(); - - if ( - value.length > 0 && - !includeOrganizations.some((v) => v.text === value) - ) { - setIncludeOrganizations([ - ...includeOrganizations, - { id: includeOrganizations.length, text: value }, - ]); - updateFilter('filterIncludeOrganizations', value as Organization, true); - - (event.target as HTMLInputElement).value = ''; - } - }; - - const removeIncludeOrganizationToken = (tokenId: string | number) => { - const value = - includeOrganizations.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterIncludeOrganizations', value as Organization, false); - - setIncludeOrganizations( - includeOrganizations.filter((v) => v.id !== tokenId), - ); - }; - - const includeOrganizationsKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addIncludeOrganizationsToken(event); - } - }; - - const [excludeOrganizations, setExcludeOrganizations] = useState< - InputToken[] - >(mapValuesToTokens(settings.filterExcludeOrganizations)); - - const addExcludeOrganizationsToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const value = (event.target as HTMLInputElement).value.trim(); - - if ( - value.length > 0 && - !excludeOrganizations.some((v) => v.text === value) - ) { - setExcludeOrganizations([ - ...excludeOrganizations, - { id: excludeOrganizations.length, text: value }, - ]); - updateFilter('filterExcludeOrganizations', value as Organization, true); - - (event.target as HTMLInputElement).value = ''; - } - }; - - const removeExcludeOrganizationToken = (tokenId: string | number) => { - const value = - excludeOrganizations.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterExcludeOrganizations', value as Organization, false); - - setExcludeOrganizations( - excludeOrganizations.filter((v) => v.id !== tokenId), - ); - }; - - const excludeOrganizationsKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addExcludeOrganizationsToken(event); - } - }; - - return ( -
- - Organizations - - Filter notifications by organization. - - } - /> - - - - - - - Include: - - - - - - - - - - Exclude: - - - - - -
- ); -}; diff --git a/src/renderer/components/filters/ReasonFilter.test.tsx b/src/renderer/components/filters/ReasonFilter.test.tsx index 9fe104f18..81beb41ce 100644 --- a/src/renderer/components/filters/ReasonFilter.test.tsx +++ b/src/renderer/components/filters/ReasonFilter.test.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; @@ -15,9 +14,7 @@ describe('renderer/components/filters/ReasonFilter.tsx', () => { notifications: mockAccountNotifications, }} > - - - + , ); diff --git a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx index f0348091f..cd57cb5f8 100644 --- a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx +++ b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.test.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; @@ -15,9 +14,7 @@ describe('renderer/components/filters/RequiresDetailedNotificationsWarning.tsx', notifications: mockAccountNotifications, }} > - - - + , ); diff --git a/src/renderer/components/filters/SearchFilter.test.tsx b/src/renderer/components/filters/SearchFilter.test.tsx new file mode 100644 index 000000000..2299ce16c --- /dev/null +++ b/src/renderer/components/filters/SearchFilter.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import { SearchFilter } from './SearchFilter'; + +const updateFilter = jest.fn(); + +describe('renderer/components/filters/SearchFilter.tsx', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Include Search Tokens', () => { + it('adds include actor token with prefix', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Include searches'); + fireEvent.change(includeInput, { target: { value: 'author:octocat' } }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterIncludeSearchTokens', + 'author:octocat', + true, + ); + }); + + it('adds include org token with prefix', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Include searches'); + fireEvent.change(includeInput, { target: { value: 'org:gitify-app' } }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterIncludeSearchTokens', + 'org:gitify-app', + true, + ); + }); + + it('adds include repo token with prefix', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Include searches'); + fireEvent.change(includeInput, { + target: { value: 'repo:gitify-app/gitify' }, + }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterIncludeSearchTokens', + 'repo:gitify-app/gitify', + true, + ); + }); + + it('prevent unrecognized include prefixes', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Include searches'); + fireEvent.change(includeInput, { + target: { value: 'some:search' }, + }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).not.toHaveBeenCalledWith(); + }); + }); + + describe('Exclude Search Tokens', () => { + it('adds exclude actor token with prefix', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Exclude searches'); + fireEvent.change(includeInput, { target: { value: 'author:octocat' } }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterExcludeSearchTokens', + 'author:octocat', + true, + ); + }); + + it('adds exclude org token with prefix', () => { + render( + + + , + ); + + const excludeInput = screen.getByTitle('Exclude searches'); + fireEvent.change(excludeInput, { target: { value: 'org:gitify-app' } }); + fireEvent.keyDown(excludeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterExcludeSearchTokens', + 'org:gitify-app', + true, + ); + }); + + it('adds exclude repo token with prefix', () => { + render( + + + , + ); + + const excludeInput = screen.getByTitle('Exclude searches'); + fireEvent.change(excludeInput, { + target: { value: 'repo:gitify-app/gitify' }, + }); + fireEvent.keyDown(excludeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterExcludeSearchTokens', + 'repo:gitify-app/gitify', + true, + ); + }); + + it('prevent unrecognized exclude prefixes', () => { + render( + + + , + ); + + const excludeInput = screen.getByTitle('Exclude searches'); + fireEvent.change(excludeInput, { + target: { value: 'some:search' }, + }); + fireEvent.keyDown(excludeInput, { key: 'Enter' }); + + expect(updateFilter).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx new file mode 100644 index 000000000..0183b63e8 --- /dev/null +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -0,0 +1,142 @@ +import { type FC, useContext, useEffect, useState } from 'react'; + +import { + CheckCircleFillIcon, + NoEntryFillIcon, + OrganizationIcon, + PersonIcon, + RepoIcon, + SearchIcon, +} from '@primer/octicons-react'; +import { Box, Stack, Text } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import { IconColor, type SearchToken, Size } from '../../types'; +import { cn } from '../../utils/cn'; +import { + hasExcludeSearchFilters, + hasIncludeSearchFilters, +} from '../../utils/notifications/filters/search'; +import { Title } from '../primitives/Title'; +import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; +import { TokenSearchInput } from './TokenSearchInput'; + +export const SearchFilter: FC = () => { + const { updateFilter, settings } = useContext(AppContext); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on search filter changes + useEffect(() => { + if (!hasIncludeSearchFilters(settings)) { + setIncludeSearchTokens([]); + } + + if (!hasExcludeSearchFilters(settings)) { + setExcludeSearchTokens([]); + } + }, [settings.filterIncludeSearchTokens, settings.filterExcludeSearchTokens]); + + const [includeSearchTokens, setIncludeSearchTokens] = useState( + settings.filterIncludeSearchTokens, + ); + + const addIncludeSearchToken = (value: string) => { + if (!value || includeSearchTokens.includes(value as SearchToken)) { + return; + } + + setIncludeSearchTokens([...includeSearchTokens, value as SearchToken]); + updateFilter('filterIncludeSearchTokens', value as SearchToken, true); + }; + + const removeIncludeSearchToken = (token: SearchToken) => { + if (!token) { + return; + } + + updateFilter('filterIncludeSearchTokens', token, false); + setIncludeSearchTokens(includeSearchTokens.filter((t) => t !== token)); + }; + + const [excludeSearchTokens, setExcludeSearchTokens] = useState( + settings.filterExcludeSearchTokens, + ); + + const addExcludeSearchToken = (value: string) => { + if (!value || excludeSearchTokens.includes(value as SearchToken)) { + return; + } + + setExcludeSearchTokens([...excludeSearchTokens, value as SearchToken]); + updateFilter('filterExcludeSearchTokens', value as SearchToken, true); + }; + + const removeExcludeSearchToken = (token: SearchToken) => { + if (!token) { + return; + } + + updateFilter('filterExcludeSearchTokens', token, false); + setExcludeSearchTokens(excludeSearchTokens.filter((t) => t !== token)); + }; + + return ( +
+ + <Text>Filter notifications by:</Text> + <Box className="pl-4"> + <Stack direction="vertical" gap="condensed"> + <Stack direction="horizontal" gap="condensed"> + <PersonIcon size={Size.SMALL} /> + <Text + className={cn( + 'text-gitify-caution', + !settings.detailedNotifications && 'line-through', + )} + > + Author (author:handle) + </Text> + </Stack> + <Stack direction="horizontal" gap="condensed"> + <OrganizationIcon size={Size.SMALL} /> + <Text>Organization (org:name)</Text> + </Stack> + <Stack direction="horizontal" gap="condensed"> + <RepoIcon size={Size.SMALL} /> + <Text>Repository (repo:fullname)</Text> + </Stack> + </Stack> + </Box> + <RequiresDetailedNotificationWarning /> + </Stack> + } + > + Search + + + + + + + +
+ ); +}; diff --git a/src/renderer/components/filters/SearchFilterSuggestions.test.tsx b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx new file mode 100644 index 000000000..092d1de03 --- /dev/null +++ b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx @@ -0,0 +1,115 @@ +import { render } from '@testing-library/react'; + +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import type { SettingsState } from '../../types'; +import { SearchFilterSuggestions } from './SearchFilterSuggestions'; + +describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { + const mockOnClose = jest.fn(); + + it('should render itself & its children - closed', () => { + const tree = render( + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - open', () => { + const tree = render( + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - open with detailed enabled', () => { + const tree = render( + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - input token invalid', () => { + const tree = render( + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - input token valid', () => { + const tree = render( + + + , + ); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx new file mode 100644 index 000000000..6bcf0ebd8 --- /dev/null +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -0,0 +1,72 @@ +import { type FC, useContext } from 'react'; + +import { Box, Popover, Stack, Text } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import { Opacity } from '../../types'; +import { cn } from '../../utils/cn'; +import { + ALL_SEARCH_QUALIFIERS, + BASE_SEARCH_QUALIFIERS, + SEARCH_DELIMITER, +} from '../../utils/notifications/filters/search'; + +interface SearchFilterSuggestionsProps { + open: boolean; + inputValue: string; + onClose: () => void; +} + +export const SearchFilterSuggestions: FC = ({ + open, + inputValue, + onClose, +}) => { + const { settings } = useContext(AppContext); + + if (!open) { + return null; + } + + const lower = inputValue.toLowerCase(); + const base = settings.detailedNotifications + ? ALL_SEARCH_QUALIFIERS + : BASE_SEARCH_QUALIFIERS; + const suggestions = base.filter( + (q) => q.prefix.startsWith(lower) || inputValue === '', + ); + const beginsWithKnownQualifier = base.some((q) => lower.startsWith(q.prefix)); + + return ( + + + + {suggestions.length > 0 && + suggestions.map((q) => ( + + + {q.prefix} + + {q.description} + + + + ))} + {inputValue !== '' && + suggestions.length === 0 && + !beginsWithKnownQualifier && ( + + + Please use one of the supported filters [ + {base + .map((q) => q.prefix.replace(SEARCH_DELIMITER, '')) + .join(', ')} + ] + + + )} + + + + ); +}; diff --git a/src/renderer/components/filters/StateFilter.test.tsx b/src/renderer/components/filters/StateFilter.test.tsx index 264bafac3..a924580ac 100644 --- a/src/renderer/components/filters/StateFilter.test.tsx +++ b/src/renderer/components/filters/StateFilter.test.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; @@ -19,9 +18,7 @@ describe('renderer/components/filters/StateFilter.tsx', () => { notifications: mockAccountNotifications, }} > - - - + , ); diff --git a/src/renderer/components/filters/SubjectTypeFilter.test.tsx b/src/renderer/components/filters/SubjectTypeFilter.test.tsx index e6c86b800..1015df381 100644 --- a/src/renderer/components/filters/SubjectTypeFilter.test.tsx +++ b/src/renderer/components/filters/SubjectTypeFilter.test.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; @@ -18,9 +17,7 @@ describe('renderer/components/filters/SubjectTypeFilter.tsx', () => { notifications: mockAccountNotifications, }} > - - - + , ); diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx new file mode 100644 index 000000000..e80c39321 --- /dev/null +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -0,0 +1,122 @@ +import { type FC, useState } from 'react'; + +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; + +import type { SearchToken } from '../../types'; +import { + parseSearchInput, + SEARCH_DELIMITER, +} from '../../utils/notifications/filters/search'; +import { SearchFilterSuggestions } from './SearchFilterSuggestions'; + +interface TokenSearchInputProps { + label: string; + icon: FC<{ className?: string }>; + iconColorClass: string; + tokens: readonly SearchToken[]; // raw token strings + showSuggestionsOnFocusIfEmpty: boolean; + onAdd: (token: SearchToken) => void; + onRemove: (token: SearchToken) => void; +} + +const INPUT_KEY_EVENTS = ['Enter', 'Tab', ' ', ',']; + +export const TokenSearchInput: FC = ({ + label, + icon: Icon, + iconColorClass, + tokens, + showSuggestionsOnFocusIfEmpty, + onAdd, + onRemove, +}) => { + const [inputValue, setInputValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + + const tokenItems = tokens.map((text, id) => ({ id, text })); + + function tryAddToken( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) { + const raw = (event.target as HTMLInputElement).value; + const parsed = parseSearchInput(raw); + const token = parsed?.token as SearchToken | undefined; + if (token && !tokens.includes(token)) { + onAdd(token); + (event.target as HTMLInputElement).value = ''; + setInputValue(''); + } + } + + function onKeyDown(e: React.KeyboardEvent) { + if (INPUT_KEY_EVENTS.includes(e.key)) { + tryAddToken(e); + setShowSuggestions(false); + } else if (e.key === 'ArrowDown') { + setShowSuggestions(true); + } + } + + return ( + + + + + {label}: + + + + { + tryAddToken(e); + setShowSuggestions(false); + }} + onChange={(e: React.ChangeEvent) => { + setInputValue(e.target.value); + const val = e.target.value.trim(); + if ( + !val.includes(SEARCH_DELIMITER) || + val.endsWith(SEARCH_DELIMITER) + ) { + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + }} + onFocus={(e) => { + if ( + showSuggestionsOnFocusIfEmpty && + (e.target as HTMLInputElement).value.trim() === '' + ) { + setShowSuggestions(true); + } + }} + onKeyDown={onKeyDown} + onTokenRemove={(id) => { + const token = tokenItems.find((t) => t.id === id)?.text; + + if (token) { + onRemove(token); + } + }} + size="small" + title={`${label} searches`} + tokens={tokenItems} + /> + setShowSuggestions(false)} + open={showSuggestions} + /> + + + ); +}; diff --git a/src/renderer/components/filters/UserHandleFilter.test.tsx b/src/renderer/components/filters/UserHandleFilter.test.tsx deleted file mode 100644 index 796791cf2..000000000 --- a/src/renderer/components/filters/UserHandleFilter.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; - -import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; -import { mockSettings } from '../../__mocks__/state-mocks'; -import { AppContext } from '../../context/App'; -import type { SettingsState } from '../../types'; -import { UserHandleFilter } from './UserHandleFilter'; - -describe('renderer/components/filters/UserHandleFilter.tsx', () => { - const updateFilter = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('should render itself & its children', () => { - it('with detailed notifications enabled', () => { - const tree = render( - - - - - , - ); - - expect(tree).toMatchSnapshot(); - }); - - it('with detailed notifications disabled', () => { - const tree = render( - - - - - , - ); - - expect(tree).toMatchSnapshot(); - }); - }); - - describe('Include user handles', () => { - it('should be able to filter by include user handle - none already set', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - await userEvent.type( - screen.getByTitle('Include handles'), - 'github-user{enter}', - ); - - expect(updateFilter).toHaveBeenCalledWith( - 'filterIncludeHandles', - 'github-user', - true, - ); - }); - - it('should not allow duplicate include user handle', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - await userEvent.type( - screen.getByTitle('Include handles'), - 'github-user{enter}', - ); - - expect(updateFilter).toHaveBeenCalledTimes(0); - }); - }); - - describe('Exclude user handles', () => { - it('should be able to filter by exclude user handle - none already set', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - await userEvent.type( - screen.getByTitle('Exclude handles'), - 'github-user{enter}', - ); - - expect(updateFilter).toHaveBeenCalledWith( - 'filterExcludeHandles', - 'github-user', - true, - ); - }); - - it('should not allow duplicate exclude user handle', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - await userEvent.type( - screen.getByTitle('Exclude handles'), - 'github-user{enter}', - ); - - expect(updateFilter).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/src/renderer/components/filters/UserHandleFilter.tsx b/src/renderer/components/filters/UserHandleFilter.tsx deleted file mode 100644 index 9c96a7e7b..000000000 --- a/src/renderer/components/filters/UserHandleFilter.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { type FC, useContext, useEffect, useState } from 'react'; - -import { - CheckCircleFillIcon, - MentionIcon, - NoEntryFillIcon, -} from '@primer/octicons-react'; -import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; - -import { AppContext } from '../../context/App'; -import { IconColor, type UserHandle } from '../../types'; -import { - hasExcludeHandleFilters, - hasIncludeHandleFilters, -} from '../../utils/notifications/filters/handles'; -import { Tooltip } from '../fields/Tooltip'; -import { Title } from '../primitives/Title'; -import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; - -type InputToken = { - id: number; - text: string; -}; - -const tokenEvents = ['Enter', 'Tab', ' ', ',']; - -export const UserHandleFilter: FC = () => { - const { updateFilter, settings } = useContext(AppContext); - - // biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on handle filter changes - useEffect(() => { - if (!hasIncludeHandleFilters(settings)) { - setIncludeHandles([]); - } - - if (!hasExcludeHandleFilters(settings)) { - setExcludeHandles([]); - } - }, [settings.filterIncludeHandles, settings.filterExcludeHandles]); - - const mapValuesToTokens = (values: string[]): InputToken[] => { - return values.map((value, index) => ({ - id: index, - text: value, - })); - }; - - const [includeHandles, setIncludeHandles] = useState( - mapValuesToTokens(settings.filterIncludeHandles), - ); - - const addIncludeHandlesToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const value = (event.target as HTMLInputElement).value.trim(); - - if (value.length > 0 && !includeHandles.some((v) => v.text === value)) { - setIncludeHandles([ - ...includeHandles, - { id: includeHandles.length, text: value }, - ]); - updateFilter('filterIncludeHandles', value as UserHandle, true); - - (event.target as HTMLInputElement).value = ''; - } - }; - - const removeIncludeHandleToken = (tokenId: string | number) => { - const value = includeHandles.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterIncludeHandles', value as UserHandle, false); - - setIncludeHandles(includeHandles.filter((v) => v.id !== tokenId)); - }; - - const includeHandlesKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addIncludeHandlesToken(event); - } - }; - - const [excludeHandles, setExcludeHandles] = useState( - mapValuesToTokens(settings.filterExcludeHandles), - ); - - const addExcludeHandlesToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const value = (event.target as HTMLInputElement).value.trim(); - - if (value.length > 0 && !excludeHandles.some((v) => v.text === value)) { - setExcludeHandles([ - ...excludeHandles, - { id: excludeHandles.length, text: value }, - ]); - updateFilter('filterExcludeHandles', value as UserHandle, true); - - (event.target as HTMLInputElement).value = ''; - } - }; - - const removeExcludeHandleToken = (tokenId: string | number) => { - const value = excludeHandles.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterExcludeHandles', value as UserHandle, false); - - setExcludeHandles(excludeHandles.filter((v) => v.id !== tokenId)); - }; - - const excludeHandlesKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addExcludeHandlesToken(event); - } - }; - - return ( -
- - Handles - - Filter notifications by user handle. - - - } - /> - - - - - - - Include: - - - - - - - - - - Exclude: - - - - - -
- ); -}; diff --git a/src/renderer/components/filters/UserTypeFilter.test.tsx b/src/renderer/components/filters/UserTypeFilter.test.tsx index c1433500a..fa3009361 100644 --- a/src/renderer/components/filters/UserTypeFilter.test.tsx +++ b/src/renderer/components/filters/UserTypeFilter.test.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; import { mockAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; @@ -19,9 +18,7 @@ describe('renderer/components/filters/UserTypeFilter.tsx', () => { notifications: mockAccountNotifications, }} > - - - + , ); diff --git a/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap b/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap index baa7eb0d1..c080cd2dc 100644 --- a/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap +++ b/src/renderer/components/filters/__snapshots__/FilterSection.test.tsx.snap @@ -443,55 +443,45 @@ exports[`renderer/components/filters/FilterSection.tsx should render itself & it
-
- + +
- + FilterSectionTitle +
- -
+
+
-
- + +
- + + +

+ FilterSectionTitle +

- -
+
+
-
- + +
- + FilterSectionTitle +
- -
+
+
-
- + +
- + + +

+ FilterSectionTitle +

- -
+
+
-
- + +
+ +

+ Reason +

-

- Reason -

- -
-
-
+
-
- + +
+ +

+ Reason +

-

- Reason -

- -
-
-
+
+
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render itself & its children - input token invalid 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+ + Please use one of the supported filters [ + org, repo + ] + +
+
+
+
+
+ , + "container":
+
+
+
+
+ + Please use one of the supported filters [ + org, repo + ] + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render itself & its children - input token valid 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render itself & its children - open 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+ + author: + + + filter by notification author + +
+
+
+
+ + org: + + + filter by organization owner + +
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+ + author: + + + filter by notification author + +
+
+
+
+ + org: + + + filter by organization owner + +
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render itself & its children - open with detailed enabled 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+ + author: + + + filter by notification author + +
+
+
+
+ + org: + + + filter by organization owner + +
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+ + author: + + + filter by notification author + +
+
+
+
+ + org: + + + filter by organization owner + +
+
+
+
+ + repo: + + + filter by repository full name + +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap index be2e10838..0a1cff88f 100644 --- a/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap +++ b/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap @@ -8,32 +8,54 @@ exports[`renderer/components/filters/StateFilter.tsx should render itself & its
-
- + +
+ +

+ State +

-

- State -

- -
-
-
+
-
- + +
+ +

+ State +

-

- State -

- -
-
-
+
-
- + +
+ +

+ Type +

-

- Type -

- -
-
-
+
-
- + +
+ +

+ Type +

-

- Type -

- -
-
-
+
-
-
-
- -
-
- -

- Handles -

-
-
-
-
- -
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
- , - "container":
-
-
- -
-
- -

- Handles -

-
-
-
-
- -
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} -`; - -exports[`renderer/components/filters/UserHandleFilter.tsx should render itself & its children with detailed notifications enabled 1`] = ` -{ - "asFragment": [Function], - "baseElement": -
-
-
- -
-
- -

- Handles -

-
-
-
-
- -
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
- , - "container":
-
-
- -
-
- -

- Handles -

-
-
-
-
- -
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} -`; diff --git a/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap index 088c5eadf..69c78ceb7 100644 --- a/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap +++ b/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap @@ -8,32 +8,51 @@ exports[`renderer/components/filters/UserTypeFilter.tsx should render itself & i
-
- + +
+ +

+ User Type +

-

- User Type -

- -
-
-
+
-
- + +
+ +

+ User Type +

-

- User Type -

- -
-
-
+
{ it('should render itself & its children', () => { - const tree = render(Test); + const tree = render(Test); expect(tree).toMatchSnapshot(); }); diff --git a/src/renderer/components/layout/Page.tsx b/src/renderer/components/layout/Page.tsx index cb347c199..d69148a97 100644 --- a/src/renderer/components/layout/Page.tsx +++ b/src/renderer/components/layout/Page.tsx @@ -4,7 +4,7 @@ import { Box } from '@primer/react'; interface IPage { children: ReactNode; - id: string; + testId?: string; } /** @@ -12,9 +12,9 @@ interface IPage { * It creates a column layout for header, content, and footer. * The height is 100% to fill the parent container. */ -export const Page: FC = ({ children, id }) => { +export const Page: FC = ({ children, testId }) => { return ( - + {children} ); diff --git a/src/renderer/components/notifications/AccountNotifications.tsx b/src/renderer/components/notifications/AccountNotifications.tsx index c11af63dc..5fd203fbf 100644 --- a/src/renderer/components/notifications/AccountNotifications.tsx +++ b/src/renderer/components/notifications/AccountNotifications.tsx @@ -42,7 +42,10 @@ export const AccountNotifications: FC = ( notifications.reduce( (acc: { [key: string]: Notification[] }, notification) => { const key = notification.repository.full_name; - if (!acc[key]) acc[key] = []; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(notification); return acc; }, diff --git a/src/renderer/components/primitives/Header.tsx b/src/renderer/components/primitives/Header.tsx index eeba64059..1d0ba9fec 100644 --- a/src/renderer/components/primitives/Header.tsx +++ b/src/renderer/components/primitives/Header.tsx @@ -19,7 +19,7 @@ export const Header: FC = (props: IHeader) => { const { fetchNotifications } = useContext(AppContext); return ( - + = ({ size = 2, ...props }) => { + const name = props.children.toLowerCase().replace(' ', '-'); + return ( @@ -17,10 +22,20 @@ export const Title: FC = ({ size = 2, ...props }) => { align="center" direction="horizontal" gap="condensed" - id={props.children.toLowerCase().replace(' ', '-')} + id={`title-${name}`} > {props.children} + {props.tooltip && ( + + {props.tooltip} + + } + /> + )} diff --git a/src/renderer/components/primitives/__snapshots__/Header.test.tsx.snap b/src/renderer/components/primitives/__snapshots__/Header.test.tsx.snap index 3bc0df9d2..6ab3e59d1 100644 --- a/src/renderer/components/primitives/__snapshots__/Header.test.tsx.snap +++ b/src/renderer/components/primitives/__snapshots__/Header.test.tsx.snap @@ -6,7 +6,7 @@ exports[`renderer/components/primitives/Header.tsx should render itself & its ch "baseElement":
- -
-
- -

- User Type -

-
-
-
-
- -
-
-
-
- - - - 0 - -
+
+
- -
-
- - 0 - -
-
- - - - 0 - -
-
-
-
-
- -
+

+ Search +

-

- Handles -

-
-
-
-
+
- Include: + Include + :
- -
- + > + +
-
- + +
- Exclude: + Exclude + :
- -
- + > + +
-
- + +
-
- + +
+ +

+ User Type +

-

- Organizations -

- -
-
-
+
+ + + + 0 + +
+
+ +
- + +
-
-
- -
-
+ 0
-
+
+ Organization + -
-
- -
-
+ 0
@@ -738,32 +506,51 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children
-
- + +
+ +

+ Type +

-

- Type -

- -
-
-
+
-
- + +
+ +

+ State +

-

- State -

- -
-
-
+
-
- + +
+ +

+ Reason +

-

- Reason -

- -
-
-
+