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/src/main/updater.test.ts b/src/main/updater.test.ts index 3305aea3c..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; }), @@ -66,7 +68,9 @@ describe('main/updater.ts', () => { 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/__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/filters/OrganizationFilter.test.tsx b/src/renderer/components/filters/OrganizationFilter.test.tsx deleted file mode 100644 index ab0aff667..000000000 --- a/src/renderer/components/filters/OrganizationFilter.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { mockSettings } from '../../__mocks__/state-mocks'; -import { AppContext } from '../../context/App'; -import type { SettingsState } from '../../types'; -import { OrganizationFilter } from './OrganizationFilter'; - -const mockUpdateFilter = jest.fn(); - -describe('components/filters/OrganizationFilter.tsx', () => { - 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 e0607c1ea..000000000 --- a/src/renderer/components/filters/OrganizationFilter.tsx +++ /dev/null @@ -1,205 +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 { 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 ( -
- - <Text>Filter notifications by organization.</Text> - </Stack> - } - > - Organizations - - - - - - - - Include: - - - - - - - - - - Exclude: - - - - - -
- ); -}; 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/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 86e5546fe..000000000 --- a/src/renderer/components/filters/UserHandleFilter.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -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 21a313a2f..000000000 --- a/src/renderer/components/filters/UserHandleFilter.tsx +++ /dev/null @@ -1,192 +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 { 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 ( -
- - <Text>Filter notifications by user handle.</Text> - <RequiresDetailedNotificationWarning /> - </Stack> - } - > - Handles - - - - - - - - Include: - - - - - - - - - - Exclude: - - - - - -
- ); -}; diff --git a/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap b/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap new file mode 100644 index 000000000..bd80ee8aa --- /dev/null +++ b/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap @@ -0,0 +1,832 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render itself & its children - closed 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ , + "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__/UserHandleFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap deleted file mode 100644 index 832efcaf9..000000000 --- a/src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap +++ /dev/null @@ -1,987 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`renderer/components/filters/UserHandleFilter.tsx should render itself & its children with detailed notifications disabled 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], -} -`; - -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/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/context/App.test.tsx b/src/renderer/context/App.test.tsx index 52bbb33c2..f696eca43 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -352,9 +352,11 @@ describe('renderer/context/App.tsx', () => { } as AuthState, settings: { ...mockSettings, + filterIncludeSearchTokens: defaultSettings.filterIncludeSearchTokens, + filterExcludeSearchTokens: defaultSettings.filterExcludeSearchTokens, filterUserTypes: defaultSettings.filterUserTypes, - filterIncludeHandles: defaultSettings.filterIncludeHandles, - filterExcludeHandles: defaultSettings.filterExcludeHandles, + filterSubjectTypes: defaultSettings.filterSubjectTypes, + filterStates: defaultSettings.filterStates, filterReasons: defaultSettings.filterReasons, }, }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index d417dd6e2..881e5269a 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -132,10 +132,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [ auth.accounts, settings.filterUserTypes, - settings.filterIncludeHandles, - settings.filterExcludeHandles, - settings.filterIncludeOrganizations, - settings.filterExcludeOrganizations, + settings.filterIncludeSearchTokens, + settings.filterExcludeSearchTokens, settings.filterReasons, ]); diff --git a/src/renderer/context/defaults.ts b/src/renderer/context/defaults.ts index c65402a01..31557df06 100644 --- a/src/renderer/context/defaults.ts +++ b/src/renderer/context/defaults.ts @@ -47,10 +47,8 @@ const defaultSystemSettings: SystemSettingsState = { export const defaultFilters: FilterSettingsState = { filterUserTypes: [], - filterIncludeHandles: [], - filterExcludeHandles: [], - filterIncludeOrganizations: [], - filterExcludeOrganizations: [], + filterIncludeSearchTokens: [], + filterExcludeSearchTokens: [], filterSubjectTypes: [], filterStates: [], filterReasons: [], diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index 4f06b6822..160af183e 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -3,11 +3,10 @@ import { type FC, useContext } from 'react'; import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; import { Button, Stack, Tooltip } from '@primer/react'; -import { OrganizationFilter } from '../components/filters/OrganizationFilter'; import { ReasonFilter } from '../components/filters/ReasonFilter'; +import { SearchFilter } from '../components/filters/SearchFilter'; import { StateFilter } from '../components/filters/StateFilter'; import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter'; -import { UserHandleFilter } from '../components/filters/UserHandleFilter'; import { UserTypeFilter } from '../components/filters/UserTypeFilter'; import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; @@ -26,9 +25,8 @@ export const FiltersRoute: FC = () => { + - - diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index 8eb13d0b8..7d8639512 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -97,9 +97,7 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children data-padding="none" data-wrap="nowrap" > -
+

- User Type + Search

- -
-
-
- -
-
- - - - 0 - -
-
- - -
- -
- - 0 - -
-
- - - - 0 - -
-
- -
- -
-
- -

- Handles -

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

- Organizations + User Type

+
+ User + -
-
- -
-
+ 0
+ +
- + +
-
-
- -
-
+ 0 +
+
+
+ + + + 0
diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 9960c43b1..662eec5c8 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -32,9 +32,7 @@ export type Hostname = Branded; export type Link = Branded; -export type UserHandle = Branded; - -export type Organization = Branded; +export type SearchToken = Branded; export type Status = 'loading' | 'success' | 'error'; @@ -57,12 +55,11 @@ export type SettingsValue = | FilterValue[]; export type FilterValue = - | Reason - | UserType - | UserHandle - | Organization | FilterStateType - | SubjectType; + | Reason + | SearchToken + | SubjectType + | UserType; export type SettingsState = AppearanceSettingsState & NotificationSettingsState & @@ -101,11 +98,9 @@ export interface SystemSettingsState { } export interface FilterSettingsState { + filterIncludeSearchTokens: SearchToken[]; + filterExcludeSearchTokens: SearchToken[]; filterUserTypes: UserType[]; - filterIncludeHandles: string[]; - filterExcludeHandles: string[]; - filterIncludeOrganizations: string[]; - filterExcludeOrganizations: string[]; filterSubjectTypes: SubjectType[]; filterStates: FilterStateType[]; filterReasons: Reason[]; diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 07bd76bfb..5ade78f89 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -1,7 +1,8 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; import { defaultSettings } from '../../../context/defaults'; -import type { Link, SettingsState } from '../../../types'; +import type { Link, SearchToken, SettingsState } from '../../../types'; +import type { Owner } from '../../../typesGitHub'; import { filterBaseNotifications, filterDetailedNotifications, @@ -15,26 +16,42 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { describe('filterNotifications', () => { const mockNotifications = [ - partialMockNotification({ - title: 'User authored notification', - user: { - login: 'github-user', - html_url: 'https://github.com/user' as Link, - avatar_url: - 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, - type: 'User', + partialMockNotification( + { + title: 'User authored notification', + user: { + login: 'github-user', + html_url: 'https://github.com/user' as Link, + avatar_url: + 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, + type: 'User', + }, }, - }), - partialMockNotification({ - title: 'Bot authored notification', - user: { - login: 'github-bot', - html_url: 'https://github.com/bot' as Link, - avatar_url: - 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, - type: 'Bot', + { + owner: { + login: 'gitify-app', + } as Owner, + full_name: 'gitify-app/gitify', }, - }), + ), + partialMockNotification( + { + title: 'Bot authored notification', + user: { + login: 'github-bot', + html_url: 'https://github.com/bot' as Link, + avatar_url: + 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, + type: 'Bot', + }, + }, + { + owner: { + login: 'github', + } as Owner, + full_name: 'github/github', + }, + ), ]; describe('filterBaseNotifications', () => { @@ -61,6 +78,46 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); + + it('should filter notifications that match include organization', async () => { + const result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match exclude organization', async () => { + const result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterExcludeSearchTokens: ['org:github' as SearchToken], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match include repository', async () => { + const result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match exclude repository', async () => { + const result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterExcludeSearchTokens: ['repo:github/github' as SearchToken], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); }); describe('filterDetailedNotifications', () => { @@ -69,8 +126,8 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { ...mockSettings, detailedNotifications: false, filterUserTypes: ['Bot'], - filterIncludeHandles: ['github-user'], - filterExcludeHandles: ['github-bot'], + filterIncludeSearchTokens: ['author:github-user' as SearchToken], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], filterStates: ['merged'], }); @@ -89,22 +146,22 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(result).toEqual([mockNotifications[1]]); }); - it('should filter notifications that match include user handle', async () => { + it('should filter notifications that match include author handle', async () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeHandles: ['github-user'], + filterIncludeSearchTokens: ['author:github-user' as SearchToken], }); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications that match exclude user handle', async () => { + it('should filter notifications that match exclude author handle', async () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterExcludeHandles: ['github-bot'], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], }); expect(result.length).toBe(1); @@ -122,76 +179,6 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); - - it('should filter notifications that match include organization', async () => { - // Initialize repository owner structure if it doesn't exist - if (!mockNotifications[0].repository) { - mockNotifications[0].repository = {} as any; - } - if (!mockNotifications[0].repository.owner) { - mockNotifications[0].repository.owner = {} as any; - } - if (!mockNotifications[1].repository) { - mockNotifications[1].repository = {} as any; - } - if (!mockNotifications[1].repository.owner) { - mockNotifications[1].repository.owner = {} as any; - } - - mockNotifications[0].repository.owner.login = 'microsoft'; - mockNotifications[1].repository.owner.login = 'github'; - - // Apply base filtering first (where organization filtering now happens) - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterIncludeOrganizations: ['microsoft'], - }); - - // Then apply detailed filtering - result = filterDetailedNotifications(result, { - ...mockSettings, - detailedNotifications: true, - filterIncludeOrganizations: ['microsoft'], - }); - - expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[0]]); - }); - - it('should filter notifications that match exclude organization', async () => { - // Initialize repository owner structure if it doesn't exist - if (!mockNotifications[0].repository) { - mockNotifications[0].repository = {} as any; - } - if (!mockNotifications[0].repository.owner) { - mockNotifications[0].repository.owner = {} as any; - } - if (!mockNotifications[1].repository) { - mockNotifications[1].repository = {} as any; - } - if (!mockNotifications[1].repository.owner) { - mockNotifications[1].repository.owner = {} as any; - } - - mockNotifications[0].repository.owner.login = 'microsoft'; - mockNotifications[1].repository.owner.login = 'github'; - - // Apply base filtering first (where organization filtering now happens) - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterExcludeOrganizations: ['github'], - }); - - // Then apply detailed filtering - result = filterDetailedNotifications(result, { - ...mockSettings, - detailedNotifications: true, - filterExcludeOrganizations: ['github'], - }); - - expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[0]]); - }); }); }); @@ -200,42 +187,26 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(hasAnyFiltersSet(defaultSettings)).toBe(false); }); - it('non-default user type filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterUserTypes: ['Bot'], - }; - expect(hasAnyFiltersSet(settings)).toBe(true); - }); - - it('non-default user handle includes filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterIncludeHandles: ['gitify'], - }; - expect(hasAnyFiltersSet(settings)).toBe(true); - }); - - it('non-default user handle excludes filters', () => { + it('non-default search token includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeHandles: ['gitify'], + filterIncludeSearchTokens: ['author:gitify' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); - it('non-default organization includes filters', () => { + it('non-default search token excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeOrganizations: ['microsoft'], + filterExcludeSearchTokens: ['org:github' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); - it('non-default organization excludes filters', () => { + it('non-default user type filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeOrganizations: ['github'], + filterUserTypes: ['Bot'], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index 5961dbcc7..2c1c93011 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,13 +5,13 @@ import type { SubjectUser, } from '../../../typesGitHub'; import { - filterNotificationByHandle, - filterNotificationByOrganization, - hasExcludeHandleFilters, - hasExcludeOrganizationFilters, - hasIncludeHandleFilters, - hasIncludeOrganizationFilters, + BASE_SEARCH_QUALIFIERS, + DETAILED_ONLY_SEARCH_QUALIFIERS, + filterNotificationBySearchTerm, + hasExcludeSearchFilters, + hasIncludeSearchFilters, reasonFilter, + type SearchQualifier, stateFilter, subjectTypeFilter, userTypeFilter, @@ -24,8 +24,16 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; - passesFilters = - passesFilters && passesOrganizationFilters(notification, settings); + // Apply base qualifier include/exclude filters (org, repo, etc.) + for (const qualifier of BASE_SEARCH_QUALIFIERS) { + if (!passesFilters) { + break; + } + + passesFilters = + passesFilters && + passesSearchTokenFiltersForQualifier(notification, settings, qualifier); + } if (subjectTypeFilter.hasFilters(settings)) { passesFilters = @@ -69,69 +77,77 @@ export function filterDetailedNotifications( export function hasAnyFiltersSet(settings: SettingsState): boolean { return ( userTypeFilter.hasFilters(settings) || - hasIncludeHandleFilters(settings) || - hasExcludeHandleFilters(settings) || - hasIncludeOrganizationFilters(settings) || - hasExcludeOrganizationFilters(settings) || + hasIncludeSearchFilters(settings) || + hasExcludeSearchFilters(settings) || subjectTypeFilter.hasFilters(settings) || stateFilter.hasFilters(settings) || reasonFilter.hasFilters(settings) ); } -function passesUserFilters( +/** + * Apply include/exclude search token logic for a specific search qualifier prefix. + */ +function passesSearchTokenFiltersForQualifier( notification: Notification, settings: SettingsState, + qualifier: SearchQualifier, ): boolean { - let passesFilters = true; - - if (userTypeFilter.hasFilters(settings)) { - passesFilters = - passesFilters && - settings.filterUserTypes.some((userType) => - userTypeFilter.filterNotification(notification, userType), - ); - } + let passes = true; + const prefix = qualifier.prefix; - if (hasIncludeHandleFilters(settings)) { - passesFilters = - passesFilters && - settings.filterIncludeHandles.some((handle) => - filterNotificationByHandle(notification, handle), - ); + if (hasIncludeSearchFilters(settings)) { + const includeTokens = settings.filterIncludeSearchTokens.filter((t) => + t.startsWith(prefix), + ); + if (includeTokens.length > 0) { + passes = + passes && + includeTokens.some((token) => + filterNotificationBySearchTerm(notification, token), + ); + } } - if (hasExcludeHandleFilters(settings)) { - passesFilters = - passesFilters && - !settings.filterExcludeHandles.some((handle) => - filterNotificationByHandle(notification, handle), - ); + if (hasExcludeSearchFilters(settings)) { + const excludeTokens = settings.filterExcludeSearchTokens.filter((t) => + t.startsWith(prefix), + ); + if (excludeTokens.length > 0) { + passes = + passes && + !excludeTokens.some((token) => + filterNotificationBySearchTerm(notification, token), + ); + } } - return passesFilters; + return passes; } -function passesOrganizationFilters( +function passesUserFilters( notification: Notification, settings: SettingsState, ): boolean { let passesFilters = true; - if (hasIncludeOrganizationFilters(settings)) { + if (userTypeFilter.hasFilters(settings)) { passesFilters = passesFilters && - settings.filterIncludeOrganizations.some((organization) => - filterNotificationByOrganization(notification, organization), + settings.filterUserTypes.some((userType) => + userTypeFilter.filterNotification(notification, userType), ); } - if (hasExcludeOrganizationFilters(settings)) { + // Apply detailed-only qualifier search token filters (e.g. author) + for (const qualifier of DETAILED_ONLY_SEARCH_QUALIFIERS) { + if (!passesFilters) { + break; + } + passesFilters = passesFilters && - !settings.filterExcludeOrganizations.some((organization) => - filterNotificationByOrganization(notification, organization), - ); + passesSearchTokenFiltersForQualifier(notification, settings, qualifier); } return passesFilters; diff --git a/src/renderer/utils/notifications/filters/handles.ts b/src/renderer/utils/notifications/filters/handles.ts deleted file mode 100644 index 2fe743d29..000000000 --- a/src/renderer/utils/notifications/filters/handles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SettingsState } from '../../../types'; -import type { Notification } from '../../../typesGitHub'; - -export function hasIncludeHandleFilters(settings: SettingsState) { - return settings.filterIncludeHandles.length > 0; -} - -export function hasExcludeHandleFilters(settings: SettingsState) { - return settings.filterExcludeHandles.length > 0; -} - -export function filterNotificationByHandle( - notification: Notification, - handleName: string, -): boolean { - return notification.subject?.user?.login === handleName; -} diff --git a/src/renderer/utils/notifications/filters/index.ts b/src/renderer/utils/notifications/filters/index.ts index 422bd4e12..f630337d9 100644 --- a/src/renderer/utils/notifications/filters/index.ts +++ b/src/renderer/utils/notifications/filters/index.ts @@ -1,6 +1,5 @@ -export * from './handles'; -export * from './organizations'; export * from './reason'; +export * from './search'; export * from './state'; export * from './subjectType'; export * from './types'; diff --git a/src/renderer/utils/notifications/filters/organizations.test.ts b/src/renderer/utils/notifications/filters/organizations.test.ts deleted file mode 100644 index d520e1653..000000000 --- a/src/renderer/utils/notifications/filters/organizations.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mockSettings } from '../../../__mocks__/state-mocks'; -import type { Notification } from '../../../typesGitHub'; -import { - filterNotificationByOrganization, - hasExcludeOrganizationFilters, - hasIncludeOrganizationFilters, -} from './organizations'; - -describe('utils/notifications/filters/organizations.ts', () => { - it('should check if include organization filters exist', () => { - const settingsWithInclude = { - ...mockSettings, - filterIncludeOrganizations: ['microsoft'], - }; - - expect(hasIncludeOrganizationFilters(mockSettings)).toBe(false); - expect(hasIncludeOrganizationFilters(settingsWithInclude)).toBe(true); - }); - - it('should check if exclude organization filters exist', () => { - const settingsWithExclude = { - ...mockSettings, - filterExcludeOrganizations: ['github'], - }; - - expect(hasExcludeOrganizationFilters(mockSettings)).toBe(false); - expect(hasExcludeOrganizationFilters(settingsWithExclude)).toBe(true); - }); - - it('should filter notification by organization', () => { - const notification = { - repository: { - owner: { - login: 'microsoft', - }, - }, - } as Notification; - - expect(filterNotificationByOrganization(notification, 'microsoft')).toBe( - true, - ); - expect(filterNotificationByOrganization(notification, 'github')).toBe( - false, - ); - }); -}); diff --git a/src/renderer/utils/notifications/filters/organizations.ts b/src/renderer/utils/notifications/filters/organizations.ts deleted file mode 100644 index 5084fa574..000000000 --- a/src/renderer/utils/notifications/filters/organizations.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { SettingsState } from '../../../types'; -import type { Notification } from '../../../typesGitHub'; - -export function hasIncludeOrganizationFilters(settings: SettingsState) { - return settings.filterIncludeOrganizations.length > 0; -} - -export function hasExcludeOrganizationFilters(settings: SettingsState) { - return settings.filterExcludeOrganizations.length > 0; -} - -export function filterNotificationByOrganization( - notification: Notification, - organizationName: string, -): boolean { - return ( - notification.repository.owner.login.toLowerCase() === - organizationName.toLowerCase() - ); -} diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts new file mode 100644 index 000000000..3b590925b --- /dev/null +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -0,0 +1,122 @@ +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import type { Link } from '../../../types'; +import type { Owner } from '../../../typesGitHub'; +import { + ALL_SEARCH_QUALIFIERS, + filterNotificationBySearchTerm, + parseSearchInput, +} from './search'; + +// (helper removed – no longer used) + +describe('renderer/utils/notifications/filters/search.ts', () => { + describe('parseSearchInput (prefix matching behavior)', () => { + it('returns null for empty string', () => { + expect(parseSearchInput('')).toBeNull(); + }); + + it('returns null when no qualifier prefix matches', () => { + expect(parseSearchInput('unknown:value')).toBeNull(); + expect(parseSearchInput('auth:foo')).toBeNull(); // near miss + }); + + it('matches each known qualifier by its exact prefix and additional value', () => { + for (const q of ALL_SEARCH_QUALIFIERS) { + const token = q.prefix + 'someValue'; + const parsed = parseSearchInput(token); + expect(parsed).not.toBeNull(); + expect(parsed?.qualifier).toBe(q); + } + }); + + it('does not match when prefix appears later in the token', () => { + expect(parseSearchInput('xauthor:foo')).toBeNull(); + expect(parseSearchInput('xxorg:bar')).toBeNull(); + }); + }); + + describe('filterNotificationBySearchTerm', () => { + const mockNotification = partialMockNotification( + { + title: 'User authored notification', + user: { + login: 'github-user', + html_url: 'https://github.com/user' as Link, + avatar_url: + 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4' as Link, + type: 'User', + }, + }, + { + owner: { + login: 'gitify-app', + } as Owner, + full_name: 'gitify-app/gitify', + }, + ); + + it('matches author qualifier (case-insensitive)', () => { + expect( + filterNotificationBySearchTerm(mockNotification, 'author:github-user'), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, 'author:GITHUB-USER'), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, 'author:some-bot'), + ).toBe(false); + }); + + it('matches org qualifier (case-insensitive)', () => { + expect( + filterNotificationBySearchTerm(mockNotification, 'org:gitify-app'), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, 'org:GITIFY-APP'), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, 'org:github'), + ).toBe(false); + }); + + it('matches repo qualifier (case-insensitive full_name)', () => { + expect( + filterNotificationBySearchTerm( + mockNotification, + 'repo:gitify-app/gitify', + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + 'repo:Gitify-App/Gitify', + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, 'repo:github/other'), + ).toBe(false); + }); + + it('returns false for unknown qualifier', () => { + expect( + filterNotificationBySearchTerm(mockNotification, 'unknown:thing'), + ).toBe(false); + }); + + it('returns false for empty value', () => { + expect(filterNotificationBySearchTerm(mockNotification, 'repo:')).toBe( + false, + ); + }); + + it('returns false for empty token', () => { + expect(filterNotificationBySearchTerm(mockNotification, '')).toBe(false); + }); + }); +}); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts new file mode 100644 index 000000000..16e8ce87a --- /dev/null +++ b/src/renderer/utils/notifications/filters/search.ts @@ -0,0 +1,95 @@ +import type { SettingsState } from '../../../types'; +import type { Notification } from '../../../typesGitHub'; + +export const SEARCH_DELIMITER = ':'; + +const SEARCH_QUALIFIERS = { + author: { + prefix: 'author:', + description: 'filter by notification author', + requiresDetailsNotifications: true, + extract: (n: Notification) => n.subject?.user?.login, + }, + org: { + prefix: 'org:', + description: 'filter by organization owner', + requiresDetailsNotifications: false, + extract: (n: Notification) => n.repository?.owner?.login, + }, + repo: { + prefix: 'repo:', + description: 'filter by repository full name', + requiresDetailsNotifications: false, + extract: (n: Notification) => n.repository?.full_name, + }, +} as const; + +export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; +export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; +export type SearchPrefix = SearchQualifier['prefix']; + +export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = Object.values( + SEARCH_QUALIFIERS, +) as readonly SearchQualifier[]; + +export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = + ALL_SEARCH_QUALIFIERS.filter((q) => !q.requiresDetailsNotifications); + +export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] = + ALL_SEARCH_QUALIFIERS.filter((q) => q.requiresDetailsNotifications); + +export function hasIncludeSearchFilters(settings: SettingsState) { + return settings.filterIncludeSearchTokens.length > 0; +} + +export function hasExcludeSearchFilters(settings: SettingsState) { + return settings.filterExcludeSearchTokens.length > 0; +} + +export interface ParsedSearchToken { + qualifier: SearchQualifier; // matched qualifier + value: string; // original-case value after prefix + valueLower: string; // lowercase cached + token: string; // canonical stored token (prefix + value) +} + +export function parseSearchInput(raw: string): ParsedSearchToken | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + + for (const qualifier of ALL_SEARCH_QUALIFIERS) { + if (lower.startsWith(qualifier.prefix)) { + const valuePart = trimmed.slice(qualifier.prefix.length).trim(); + if (!valuePart) { + return null; + } + + const token = qualifier.prefix + valuePart; + return { + qualifier, + value: valuePart, + valueLower: valuePart.toLowerCase(), + token, + }; + } + } + return null; +} + +export function filterNotificationBySearchTerm( + notification: Notification, + token: string, +): boolean { + const parsed = parseSearchInput(token); + + if (!parsed) { + return false; + } + + const fieldValue = parsed.qualifier.extract(notification); + return fieldValue?.toLowerCase() === parsed.valueLower; +} diff --git a/src/renderer/utils/zoom.ts b/src/renderer/utils/zoom.ts index 17bdbf8ec..290e9edb8 100644 --- a/src/renderer/utils/zoom.ts +++ b/src/renderer/utils/zoom.ts @@ -7,7 +7,10 @@ const MULTIPLIER = 2; * @returns zoomLevel -2 to 0.5 */ export const zoomPercentageToLevel = (percentage: number): number => { - if (typeof percentage === 'undefined') return 0; + if (typeof percentage === 'undefined') { + return 0; + } + return ((percentage - RECOMMENDED) * MULTIPLIER) / 100; }; @@ -17,6 +20,9 @@ export const zoomPercentageToLevel = (percentage: number): number => { * @returns percentage 0-150 */ export const zoomLevelToPercentage = (zoom: number): number => { - if (typeof zoom === 'undefined') return 100; + if (typeof zoom === 'undefined') { + return 100; + } + return (zoom / MULTIPLIER) * 100 + RECOMMENDED; };