From 28b8458187679c2fbff95174d84221e394d3d564 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 6 Sep 2025 07:40:50 -0400 Subject: [PATCH 01/26] feat: search notifications Signed-off-by: Adam Setch --- src/renderer/__mocks__/state-mocks.ts | 6 +- .../filters/OrganizationFilter.test.tsx | 130 ----------- .../components/filters/OrganizationFilter.tsx | 206 ----------------- .../components/filters/SearchFilter.test.tsx | 31 +++ .../components/filters/SearchFilter.tsx | 215 ++++++++++++++++++ .../filters/UserHandleFilter.test.tsx | 170 -------------- .../components/filters/UserHandleFilter.tsx | 193 ---------------- src/renderer/context/App.test.tsx | 4 +- src/renderer/context/App.tsx | 6 +- src/renderer/context/defaults.ts | 6 +- src/renderer/routes/Filters.tsx | 6 +- src/renderer/types.ts | 19 +- .../notifications/filters/filter.test.ts | 124 +++++++--- .../utils/notifications/filters/filter.ts | 65 +++--- .../utils/notifications/filters/handles.ts | 17 -- .../utils/notifications/filters/index.ts | 3 +- .../filters/organizations.test.ts | 46 ---- .../notifications/filters/organizations.ts | 20 -- .../utils/notifications/filters/search.ts | 58 +++++ 19 files changed, 445 insertions(+), 880 deletions(-) delete mode 100644 src/renderer/components/filters/OrganizationFilter.test.tsx delete mode 100644 src/renderer/components/filters/OrganizationFilter.tsx create mode 100644 src/renderer/components/filters/SearchFilter.test.tsx create mode 100644 src/renderer/components/filters/SearchFilter.tsx delete mode 100644 src/renderer/components/filters/UserHandleFilter.test.tsx delete mode 100644 src/renderer/components/filters/UserHandleFilter.tsx delete mode 100644 src/renderer/utils/notifications/filters/handles.ts delete mode 100644 src/renderer/utils/notifications/filters/organizations.test.ts delete mode 100644 src/renderer/utils/notifications/filters/organizations.ts create mode 100644 src/renderer/utils/notifications/filters/search.ts 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 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/SearchFilter.test.tsx b/src/renderer/components/filters/SearchFilter.test.tsx new file mode 100644 index 000000000..49823b5f1 --- /dev/null +++ b/src/renderer/components/filters/SearchFilter.test.tsx @@ -0,0 +1,31 @@ +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(); + }); + + it('adds include actor token with prefix', () => { + render( + + + , + ); + + const includeInput = screen.getByTitle('Include actors'); + fireEvent.change(includeInput, { target: { value: 'user:octocat' } }); + fireEvent.keyDown(includeInput, { key: 'Enter' }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterIncludeActors', + 'user:octocat', + true, + ); + }); +}); diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx new file mode 100644 index 000000000..7a6ae7f3f --- /dev/null +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -0,0 +1,215 @@ +import { type FC, useContext, useEffect, useId, useState } from 'react'; + +import { + CheckCircleFillIcon, + NoEntryFillIcon, + SearchIcon, +} from '@primer/octicons-react'; +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import { IconColor, type SearchToken } from '../../types'; +import { + hasExcludeSearchFilters, + hasIncludeSearchFilters, +} from '../../utils/notifications/filters/search'; +import { Tooltip } from '../fields/Tooltip'; +import { Title } from '../primitives/Title'; +import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; + +type InputToken = { + id: number; + text: string; +}; + +const tokenEvents = ['Enter', 'Tab', ' ', ',']; + +function parseRawValue(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + if (!value.includes(':')) return null; // must include prefix already + const [prefix, rest] = value.split(':'); + if (!['author', 'org', 'repo'].includes(prefix) || rest.length === 0) + return null; + return `${prefix}:${rest}`; +} + +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 mapValuesToTokens = (values: string[]): InputToken[] => + values.map((value, index) => ({ id: index, text: value })); + + const [includeSearchTokens, setIncludeSearchTokens] = useState( + mapValuesToTokens(settings.filterIncludeSearchTokens), + ); + + const addIncludeSearchToken = ( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) => { + const raw = (event.target as HTMLInputElement).value; + const value = parseRawValue(raw); + + if (value && !includeSearchTokens.some((v) => v.text === value)) { + setIncludeSearchTokens([ + ...includeSearchTokens, + { id: includeSearchTokens.length, text: value }, + ]); + updateFilter('filterIncludeSearchTokens', value as SearchToken, true); + (event.target as HTMLInputElement).value = ''; + } + }; + + const removeIncludeSearchToken = (tokenId: string | number) => { + const value = includeSearchTokens.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterIncludeSearchTokens', value as SearchToken, false); + setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId)); + }; + + const includeSearchTokensKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (tokenEvents.includes(event.key)) { + addIncludeSearchToken(event); + } + }; + + const [excludeSearchTokens, setExcludeSearchTokens] = useState( + mapValuesToTokens(settings.filterExcludeSearchTokens), + ); + + const addExcludeSearchToken = ( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) => { + const raw = (event.target as HTMLInputElement).value; + const value = parseRawValue(raw); + + if (value && !excludeSearchTokens.some((v) => v.text === value)) { + setExcludeSearchTokens([ + ...excludeSearchTokens, + { id: excludeSearchTokens.length, text: value }, + ]); + updateFilter('filterExcludeSearchTokens', value as SearchToken, true); + (event.target as HTMLInputElement).value = ''; + } + }; + + const removeExcludeSearchToken = (tokenId: string | number) => { + const value = excludeSearchTokens.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterExcludeSearchTokens', value as SearchToken, false); + setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId)); + }; + + const excludeSearchTokensKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (tokenEvents.includes(event.key)) { + addExcludeSearchToken(event); + } + }; + + // Basic suggestions for prefixes + const fieldsetId = useId(); + + return ( +
+ + Search + + Filter notifications by: + + + + Author (author:handle) + + + Organization (org:orgname) + + + Repository (repo:reponame) + + + + + + } + /> + + + + + + + Include: + + + + + + + + + + Exclude: + + + + + +
+ ); +}; 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 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/context/App.test.tsx b/src/renderer/context/App.test.tsx index 07dcc1815..ba15d3a0e 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -354,8 +354,8 @@ describe('renderer/context/App.tsx', () => { settings: { ...mockSettings, filterUserTypes: defaultSettings.filterUserTypes, - filterIncludeHandles: defaultSettings.filterIncludeHandles, - filterExcludeHandles: defaultSettings.filterExcludeHandles, + filterIncludeActors: defaultSettings.filterIncludeSearchTokens, + filterExcludeActors: defaultSettings.filterExcludeSearchTokens, filterReasons: defaultSettings.filterReasons, }, }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 15d0ac14d..5dac43ad5 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 fff1568e5..8f6584f97 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/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..5bf1fe2a9 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -2,6 +2,7 @@ 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 { Notification } from '../../../typesGitHub'; import { filterBaseNotifications, filterDetailedNotifications, @@ -69,8 +70,8 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { ...mockSettings, detailedNotifications: false, filterUserTypes: ['Bot'], - filterIncludeHandles: ['github-user'], - filterExcludeHandles: ['github-bot'], + filterIncludeSearchTokens: ['user:github-user'], + filterExcludeSearchTokens: ['user:github-bot'], filterStates: ['merged'], }); @@ -93,7 +94,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeHandles: ['github-user'], + filterIncludeSearchTokens: ['user:github-user'], }); expect(result.length).toBe(1); @@ -104,7 +105,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterExcludeHandles: ['github-bot'], + filterExcludeSearchTokens: ['user:github-bot'], }); expect(result.length).toBe(1); @@ -125,18 +126,18 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { 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; - } + // @ts-expect-error augment mock notification repository shape + if (!mockNotifications[0].repository) + mockNotifications[0].repository = {}; + // @ts-expect-error augment mock notification repository owner + if (!mockNotifications[0].repository.owner) + mockNotifications[0].repository.owner = {}; + // @ts-expect-error augment mock notification repository shape + if (!mockNotifications[1].repository) + mockNotifications[1].repository = {}; + // @ts-expect-error augment mock notification repository owner + if (!mockNotifications[1].repository.owner) + mockNotifications[1].repository.owner = {}; mockNotifications[0].repository.owner.login = 'microsoft'; mockNotifications[1].repository.owner.login = 'github'; @@ -144,14 +145,14 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { // Apply base filtering first (where organization filtering now happens) let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeOrganizations: ['microsoft'], + filterIncludeSearchTokens: ['org:microsoft'], }); // Then apply detailed filtering result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterIncludeOrganizations: ['microsoft'], + filterIncludeSearchTokens: ['org:microsoft'], }); expect(result.length).toBe(1); @@ -160,18 +161,18 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { 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; - } + // @ts-expect-error augment mock notification repository shape + if (!mockNotifications[0].repository) + mockNotifications[0].repository = {}; + // @ts-expect-error augment mock notification repository owner + if (!mockNotifications[0].repository.owner) + mockNotifications[0].repository.owner = {}; + // @ts-expect-error augment mock notification repository shape + if (!mockNotifications[1].repository) + mockNotifications[1].repository = {}; + // @ts-expect-error augment mock notification repository owner + if (!mockNotifications[1].repository.owner) + mockNotifications[1].repository.owner = {}; mockNotifications[0].repository.owner.login = 'microsoft'; mockNotifications[1].repository.owner.login = 'github'; @@ -179,14 +180,65 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { // Apply base filtering first (where organization filtering now happens) let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterExcludeOrganizations: ['github'], + filterExcludeSearchTokens: ['org:github'], }); // Then apply detailed filtering result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterExcludeOrganizations: ['github'], + filterExcludeSearchTokens: ['org:github'], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match include repository', async () => { + // Ensure repository name structure + // @ts-expect-error augment mock shape for repository filtering + if (!mockNotifications[0].repository) + mockNotifications[0].repository = {}; + // @ts-expect-error augment mock shape for repository filtering + if (!mockNotifications[1].repository) + mockNotifications[1].repository = {}; + mockNotifications[0].repository.name = 'gitify'; + mockNotifications[1].repository.name = 'other'; + + let result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterIncludeSearchTokens: ['repo:gitify'], + }); + + result = filterDetailedNotifications(result, { + ...mockSettings, + detailedNotifications: true, + filterIncludeSearchTokens: ['repo:gitify'], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match exclude repository', async () => { + // @ts-expect-error augment mock shape for repository filtering + if (!mockNotifications[0].repository) + mockNotifications[0].repository = {}; + // @ts-expect-error augment mock shape for repository filtering + if (!mockNotifications[1].repository) + mockNotifications[1].repository = {}; + mockNotifications[0].repository.name = 'gitify'; + mockNotifications[1].repository.name = 'other'; + + let result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterExcludeSearchTokens: ['repo:other'], + }); + + result = filterDetailedNotifications(result, { + ...mockSettings, + detailedNotifications: true, + filterExcludeSearchTokens: ['repo:other'], }); expect(result.length).toBe(1); @@ -211,7 +263,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default user handle includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeHandles: ['gitify'], + filterIncludeSearchTokens: ['user:gitify'], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); @@ -219,7 +271,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default user handle excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeHandles: ['gitify'], + filterExcludeSearchTokens: ['user:gitify'], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); @@ -227,7 +279,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default organization includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeOrganizations: ['microsoft'], + filterIncludeSearchTokens: ['org:microsoft'], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); @@ -235,7 +287,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default organization excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeOrganizations: ['github'], + filterExcludeSearchTokens: ['org:github'], }; 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..5fd71a129 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,12 +5,10 @@ import type { SubjectUser, } from '../../../typesGitHub'; import { - filterNotificationByHandle, - filterNotificationByOrganization, - hasExcludeHandleFilters, - hasExcludeOrganizationFilters, - hasIncludeHandleFilters, - hasIncludeOrganizationFilters, + filterNotificationBySearchTerm, + hasExcludeSearchFilters, + hasIncludeSearchFilters, + isUserToken, reasonFilter, stateFilter, subjectTypeFilter, @@ -25,7 +23,7 @@ export function filterBaseNotifications( let passesFilters = true; passesFilters = - passesFilters && passesOrganizationFilters(notification, settings); + passesFilters && passesActorIncludeExcludeFilters(notification, settings); if (subjectTypeFilter.hasFilters(settings)) { passesFilters = @@ -69,10 +67,8 @@ 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) @@ -93,44 +89,53 @@ function passesUserFilters( ); } - if (hasIncludeHandleFilters(settings)) { - passesFilters = - passesFilters && - settings.filterIncludeHandles.some((handle) => - filterNotificationByHandle(notification, handle), - ); + // Apply user-specific actor include filters (user: prefix) during detailed filtering + if (hasIncludeSearchFilters(settings)) { + const userIncludeTokens = + settings.filterIncludeSearchTokens.filter(isUserToken); + if (userIncludeTokens.length > 0) { + passesFilters = + passesFilters && + userIncludeTokens.some((token) => + filterNotificationBySearchTerm(notification, token), + ); + } } - if (hasExcludeHandleFilters(settings)) { - passesFilters = - passesFilters && - !settings.filterExcludeHandles.some((handle) => - filterNotificationByHandle(notification, handle), - ); + if (hasExcludeSearchFilters(settings)) { + const userExcludeTokens = + settings.filterExcludeSearchTokens.filter(isUserToken); + if (userExcludeTokens.length > 0) { + passesFilters = + passesFilters && + !userExcludeTokens.some((token) => + filterNotificationBySearchTerm(notification, token), + ); + } } return passesFilters; } -function passesOrganizationFilters( +function passesActorIncludeExcludeFilters( notification: Notification, settings: SettingsState, ): boolean { let passesFilters = true; - if (hasIncludeOrganizationFilters(settings)) { + if (hasIncludeSearchFilters(settings)) { passesFilters = passesFilters && - settings.filterIncludeOrganizations.some((organization) => - filterNotificationByOrganization(notification, organization), + settings.filterIncludeSearchTokens.some((token) => + filterNotificationBySearchTerm(notification, token), ); } - if (hasExcludeOrganizationFilters(settings)) { + if (hasExcludeSearchFilters(settings)) { passesFilters = passesFilters && - !settings.filterExcludeOrganizations.some((organization) => - filterNotificationByOrganization(notification, organization), + !settings.filterExcludeSearchTokens.some((token) => + filterNotificationBySearchTerm(notification, token), ); } 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.ts b/src/renderer/utils/notifications/filters/search.ts new file mode 100644 index 000000000..3df6bd9cb --- /dev/null +++ b/src/renderer/utils/notifications/filters/search.ts @@ -0,0 +1,58 @@ +import type { SettingsState } from '../../../types'; +import type { Notification } from '../../../typesGitHub'; + +const USER_PREFIX = 'user:'; +const ORG_PREFIX = 'org:'; +const REPO_PREFIX = 'repo:'; + +export function hasIncludeSearchFilters(settings: SettingsState) { + return settings.filterIncludeSearchTokens.length > 0; +} + +export function hasExcludeSearchFilters(settings: SettingsState) { + return settings.filterExcludeSearchTokens.length > 0; +} + +export function isUserToken(token: string) { + return token.startsWith(USER_PREFIX); +} + +export function isOrgToken(token: string) { + return token.startsWith(ORG_PREFIX); +} + +export function isRepoToken(token: string) { + return token.startsWith(REPO_PREFIX); +} + +function stripPrefix(token: string) { + return token.substring(token.indexOf(':') + 1); +} + +export function filterNotificationBySearchTerm( + notification: Notification, + token: string, +): boolean { + if (isUserToken(token)) { + const handle = stripPrefix(token); + return notification.subject?.user?.login === handle; + } + + if (isOrgToken(token)) { + const org = stripPrefix(token); + const owner = notification.repository?.owner?.login; + return owner?.toLowerCase() === org.toLowerCase(); + } + + if (isRepoToken(token)) { + const repo = stripPrefix(token); + const name = notification.repository?.name; + return name?.toLowerCase() === repo.toLowerCase(); + } + + return false; +} + +export function buildSearchToken(type: 'user' | 'org' | 'repo', value: string) { + return `${type}:${value}`; +} From fc1375d9f25369478c6f284acbead93612fb76be Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 6 Sep 2025 07:50:26 -0400 Subject: [PATCH 02/26] feat: search notifications Signed-off-by: Adam Setch --- .../components/filters/SearchFilter.tsx | 9 +- .../UserHandleFilter.test.tsx.snap | 1027 ----------------- .../__snapshots__/Filters.test.tsx.snap | 416 ++----- .../notifications/filters/filter.test.ts | 10 +- 4 files changed, 107 insertions(+), 1355 deletions(-) delete mode 100644 src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 7a6ae7f3f..cde71695a 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -3,12 +3,15 @@ import { type FC, useContext, useEffect, useId, useState } from 'react'; import { CheckCircleFillIcon, NoEntryFillIcon, + OrganizationIcon, + PersonIcon, + RepoIcon, SearchIcon, } from '@primer/octicons-react'; import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; import { AppContext } from '../../context/App'; -import { IconColor, type SearchToken } from '../../types'; +import { IconColor, type SearchToken, Size } from '../../types'; import { hasExcludeSearchFilters, hasIncludeSearchFilters, @@ -138,13 +141,15 @@ export const SearchFilter: FC = () => { + Author (author:handle) + Organization (org:orgname) - Repository (repo:reponame) + Repository (repo:reponame) 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 d19785def..000000000 --- a/src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap +++ /dev/null @@ -1,1027 +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/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index dae095aab..9072393d8 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -98,197 +98,7 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children data-wrap="nowrap" >
-
- -
-
- -

- User Type -

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

- Handles + Search

@@ -500,9 +311,10 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children @@ -512,7 +324,7 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children

- Organizations + User Type

+
+ User + -
-
- -
-
+ 0
+ +
- + +
-
-
- -
-
+ 0 +
+
+
+ + + + 0
@@ -2236,11 +2014,11 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children
diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 2334db43b..03cc4a844 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -70,8 +70,8 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { ...mockSettings, detailedNotifications: false, filterUserTypes: ['Bot'], - filterIncludeSearchTokens: ['user:github-user'], - filterExcludeSearchTokens: ['user:github-bot'], + filterIncludeSearchTokens: ['author:github-user' as SearchToken], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], filterStates: ['merged'], }); @@ -90,22 +90,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, - filterIncludeSearchTokens: ['user: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, - filterExcludeSearchTokens: ['user:github-bot'], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], }); expect(result.length).toBe(1); @@ -176,14 +176,14 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { // Apply base filtering first (where organization filtering now happens) let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterExcludeSearchTokens: ['org:github'], + filterExcludeSearchTokens: ['org:github' as SearchToken], }); // Then apply detailed filtering result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterExcludeSearchTokens: ['org:github'], + filterExcludeSearchTokens: ['org:github' as SearchToken], }); expect(result.length).toBe(1); @@ -203,13 +203,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeSearchTokens: ['repo:gitify'], + filterIncludeSearchTokens: ['repo:gitify' as SearchToken], }); result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['repo:gitify'], + filterIncludeSearchTokens: ['repo:gitify' as SearchToken], }); expect(result.length).toBe(1); @@ -248,42 +248,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', () => { + it('non-default search token includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeSearchTokens: ['user:gitify'], + filterIncludeSearchTokens: ['author:gitify' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); - it('non-default user handle excludes filters', () => { + it('non-default search token excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeSearchTokens: ['user:gitify'], + filterExcludeSearchTokens: ['org:github' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); - it('non-default organization includes filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterIncludeSearchTokens: ['org:microsoft'], - }; - expect(hasAnyFiltersSet(settings)).toBe(true); - }); - - it('non-default organization excludes filters', () => { + it('non-default user type filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeSearchTokens: ['org: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 5fd71a129..ad0dc8814 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -8,7 +8,7 @@ import { filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, - isUserToken, + isAuthorToken, reasonFilter, stateFilter, subjectTypeFilter, @@ -92,7 +92,7 @@ function passesUserFilters( // Apply user-specific actor include filters (user: prefix) during detailed filtering if (hasIncludeSearchFilters(settings)) { const userIncludeTokens = - settings.filterIncludeSearchTokens.filter(isUserToken); + settings.filterIncludeSearchTokens.filter(isAuthorToken); if (userIncludeTokens.length > 0) { passesFilters = passesFilters && @@ -104,7 +104,7 @@ function passesUserFilters( if (hasExcludeSearchFilters(settings)) { const userExcludeTokens = - settings.filterExcludeSearchTokens.filter(isUserToken); + settings.filterExcludeSearchTokens.filter(isAuthorToken); if (userExcludeTokens.length > 0) { passesFilters = passesFilters && diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 3df6bd9cb..318dc656b 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -1,10 +1,12 @@ import type { SettingsState } from '../../../types'; import type { Notification } from '../../../typesGitHub'; -const USER_PREFIX = 'user:'; +const AUTHOR_PREFIX = 'author:'; const ORG_PREFIX = 'org:'; const REPO_PREFIX = 'repo:'; +export const SEARCH_PREFIXES = [AUTHOR_PREFIX, ORG_PREFIX, REPO_PREFIX]; + export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; } @@ -13,8 +15,8 @@ export function hasExcludeSearchFilters(settings: SettingsState) { return settings.filterExcludeSearchTokens.length > 0; } -export function isUserToken(token: string) { - return token.startsWith(USER_PREFIX); +export function isAuthorToken(token: string) { + return token.startsWith(AUTHOR_PREFIX); } export function isOrgToken(token: string) { @@ -33,7 +35,7 @@ export function filterNotificationBySearchTerm( notification: Notification, token: string, ): boolean { - if (isUserToken(token)) { + if (isAuthorToken(token)) { const handle = stripPrefix(token); return notification.subject?.user?.login === handle; } @@ -53,6 +55,9 @@ export function filterNotificationBySearchTerm( return false; } -export function buildSearchToken(type: 'user' | 'org' | 'repo', value: string) { +export function buildSearchToken( + type: 'author' | 'org' | 'repo', + value: string, +) { return `${type}:${value}`; } From b32ceb4c4f4c6a1b86fab5ffd7936709cdf9befe Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 6 Sep 2025 09:33:42 -0400 Subject: [PATCH 04/26] feat: search notifications Signed-off-by: Adam Setch --- .../components/filters/SearchFilter.tsx | 85 +++++++++++----- .../notifications/filters/filter.test.ts | 99 +++++++++---------- .../utils/notifications/filters/search.ts | 2 +- 3 files changed, 107 insertions(+), 79 deletions(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index a359398d1..46e581dc4 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -33,27 +33,30 @@ type InputToken = { id: number; text: string }; type Qualifier = { key: string; // the qualifier prefix shown to user (author, org, repo) description: string; - example: string; }; const QUALIFIERS: Qualifier[] = [ - { - key: 'author:', - description: 'Filter by notification author', - example: 'author:octocat', - }, - { - key: 'org:', - description: 'Filter by organization owner', - example: 'org:microsoft', - }, - { - key: 'repo:', - description: 'Filter by repository full name', - example: 'repo:gitify-app/gitify', - }, + { key: 'author:', description: 'Filter by notification author' }, + { key: 'org:', description: 'Filter by organization owner' }, + { key: 'repo:', description: 'Filter by repository full name' }, ]; +const INCLUDE_EXAMPLES: Record = { + 'author:': 'author:octocat', + 'org:': 'org:gitify-app', + 'repo:': 'repo:gitify-app/gitify', +}; + +const EXCLUDE_EXAMPLES: Record = { + 'author:': 'author:spambot', + 'org:': 'org:hooli', + 'repo:': 'repo:hooli/nucleas', +}; + +function getExample(key: string, mode: 'include' | 'exclude') { + return (mode === 'include' ? INCLUDE_EXAMPLES : EXCLUDE_EXAMPLES)[key] || ''; +} + const tokenEvents = ['Enter', 'Tab', ' ', ',']; function parseRawValue(raw: string): string | null { @@ -209,7 +212,7 @@ export const SearchFilter: FC = () => { direction="horizontal" gap="condensed" > - + Include: @@ -236,9 +239,17 @@ export const SearchFilter: FC = () => { setShowIncludeSuggestions(false); } }} + onFocus={(e) => { + if ( + !hasExcludeSearchFilters(settings) && + !!settings.detailedNotifications && + (e.target as HTMLInputElement).value.trim() === '' + ) { + setShowIncludeSuggestions(true); + } + }} onKeyDown={includeSearchTokensKeyDown} onTokenRemove={removeIncludeSearchToken} - placeholder="author:octocat org:microsoft repo:gitify" size="small" title="Include searches" tokens={includeSearchTokens} @@ -271,11 +282,18 @@ export const SearchFilter: FC = () => { setShowIncludeSuggestions(false); }} > - - {q.key} - + + {q.key} + {q.description} + + {getExample(q.key, 'include')} + ))} @@ -292,7 +310,7 @@ export const SearchFilter: FC = () => { direction="horizontal" gap="condensed" > - + Exclude: @@ -318,9 +336,17 @@ export const SearchFilter: FC = () => { setShowExcludeSuggestions(false); } }} + onFocus={(e) => { + if ( + !hasIncludeSearchFilters(settings) && + !!settings.detailedNotifications && + (e.target as HTMLInputElement).value.trim() === '' + ) { + setShowExcludeSuggestions(true); + } + }} onKeyDown={excludeSearchTokensKeyDown} onTokenRemove={removeExcludeSearchToken} - placeholder="author:spambot org:legacycorp repo:oldrepo" size="small" title="Exclude searches" tokens={excludeSearchTokens} @@ -353,11 +379,18 @@ export const SearchFilter: FC = () => { setShowExcludeSuggestions(false); }} > - - {q.key} - + + {q.key} + {q.description} + + {getExample(q.key, 'exclude')} + ))} diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 03cc4a844..71a184c76 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -2,7 +2,7 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; import { defaultSettings } from '../../../context/defaults'; import type { Link, SearchToken, SettingsState } from '../../../types'; -import type { Notification } from '../../../typesGitHub'; +import type { Repository } from '../../../typesGitHub'; import { filterBaseNotifications, filterDetailedNotifications, @@ -125,34 +125,29 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications that match include organization', async () => { - // Initialize repository owner structure if it doesn't exist - // @ts-expect-error augment mock notification repository shape - if (!mockNotifications[0].repository) - mockNotifications[0].repository = {}; - // @ts-expect-error augment mock notification repository owner - if (!mockNotifications[0].repository.owner) - mockNotifications[0].repository.owner = {}; - // @ts-expect-error augment mock notification repository shape - if (!mockNotifications[1].repository) - mockNotifications[1].repository = {}; - // @ts-expect-error augment mock notification repository owner - if (!mockNotifications[1].repository.owner) - mockNotifications[1].repository.owner = {}; - - mockNotifications[0].repository.owner.login = 'microsoft'; - mockNotifications[1].repository.owner.login = 'github'; + mockNotifications[1].repository = { + owner: { + login: 'gitify-app', + }, + } as Repository; + + mockNotifications[1].repository = { + owner: { + login: 'github', + }, + } as Repository; // Apply base filtering first (where organization filtering now happens) let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeSearchTokens: ['org:microsoft' as SearchToken], + filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], }); // Then apply detailed filtering result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['org:microsoft' as SearchToken], + filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], }); expect(result.length).toBe(1); @@ -160,18 +155,17 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); 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 = {}; - if (!mockNotifications[0].repository.owner) - mockNotifications[0].repository.owner = {}; - if (!mockNotifications[1].repository) - mockNotifications[1].repository = {}; - if (!mockNotifications[1].repository.owner) - mockNotifications[1].repository.owner = {}; - - mockNotifications[0].repository.owner.login = 'microsoft'; - mockNotifications[1].repository.owner.login = 'github'; + mockNotifications[1].repository = { + owner: { + login: 'gitify-app', + }, + } as Repository; + + mockNotifications[1].repository = { + owner: { + login: 'github', + }, + } as Repository; // Apply base filtering first (where organization filtering now happens) let result = filterBaseNotifications(mockNotifications, { @@ -191,25 +185,23 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications that match include repository', async () => { - // Ensure repository name structure - // @ts-expect-error augment mock shape for repository filtering - if (!mockNotifications[0].repository) - mockNotifications[0].repository = {}; - // @ts-expect-error augment mock shape for repository filtering - if (!mockNotifications[1].repository) - mockNotifications[1].repository = {}; - mockNotifications[0].repository.name = 'gitify'; - mockNotifications[1].repository.name = 'other'; + mockNotifications[1].repository = { + full_name: 'gitify-app/gitify', + } as Repository; + + mockNotifications[1].repository = { + full_name: 'other/other', + } as Repository; let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeSearchTokens: ['repo:gitify' as SearchToken], + filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], }); result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['repo:gitify' as SearchToken], + filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], }); expect(result.length).toBe(1); @@ -217,24 +209,27 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications that match exclude repository', async () => { - // @ts-expect-error augment mock shape for repository filtering - if (!mockNotifications[0].repository) - mockNotifications[0].repository = {}; - // @ts-expect-error augment mock shape for repository filtering - if (!mockNotifications[1].repository) - mockNotifications[1].repository = {}; - mockNotifications[0].repository.name = 'gitify'; - mockNotifications[1].repository.name = 'other'; + mockNotifications[1].repository = { + id: 1, + name: 'gitify', + full_name: 'gitify-app/gitify', + } as Repository; + + mockNotifications[1].repository = { + id: 2, + name: 'other', + full_name: 'other/other', + } as Repository; let result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterExcludeSearchTokens: ['repo:other'], + filterExcludeSearchTokens: ['repo:other/other' as SearchToken], }); result = filterDetailedNotifications(result, { ...mockSettings, detailedNotifications: true, - filterExcludeSearchTokens: ['repo:other'], + filterExcludeSearchTokens: ['repo:other/other' as SearchToken], }); expect(result.length).toBe(1); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 318dc656b..6612a90d5 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -48,7 +48,7 @@ export function filterNotificationBySearchTerm( if (isRepoToken(token)) { const repo = stripPrefix(token); - const name = notification.repository?.name; + const name = notification.repository?.full_name; return name?.toLowerCase() === repo.toLowerCase(); } From 7b315e73fd9ae5a85086328db7fc007989518f0b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 6 Sep 2025 10:42:14 -0400 Subject: [PATCH 05/26] feat: search notifications Signed-off-by: Adam Setch --- .../components/filters/SearchFilter.tsx | 154 ++---------------- .../filters/SearchFilterSuggestions.tsx | 42 +++++ .../utils/notifications/filters/filter.ts | 12 +- .../utils/notifications/filters/search.ts | 54 +++--- 4 files changed, 95 insertions(+), 167 deletions(-) create mode 100644 src/renderer/components/filters/SearchFilterSuggestions.tsx diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 46e581dc4..db47bc840 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -8,14 +8,7 @@ import { RepoIcon, SearchIcon, } from '@primer/octicons-react'; -import { - ActionList, - Box, - Popover, - Stack, - Text, - TextInputWithTokens, -} from '@primer/react'; +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; import { AppContext } from '../../context/App'; import { IconColor, type SearchToken, Size } from '../../types'; @@ -30,32 +23,7 @@ import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificat type InputToken = { id: number; text: string }; -type Qualifier = { - key: string; // the qualifier prefix shown to user (author, org, repo) - description: string; -}; - -const QUALIFIERS: Qualifier[] = [ - { key: 'author:', description: 'Filter by notification author' }, - { key: 'org:', description: 'Filter by organization owner' }, - { key: 'repo:', description: 'Filter by repository full name' }, -]; - -const INCLUDE_EXAMPLES: Record = { - 'author:': 'author:octocat', - 'org:': 'org:gitify-app', - 'repo:': 'repo:gitify-app/gitify', -}; - -const EXCLUDE_EXAMPLES: Record = { - 'author:': 'author:spambot', - 'org:': 'org:hooli', - 'repo:': 'repo:hooli/nucleas', -}; - -function getExample(key: string, mode: 'include' | 'exclude') { - return (mode === 'include' ? INCLUDE_EXAMPLES : EXCLUDE_EXAMPLES)[key] || ''; -} +import { SearchFilterSuggestions } from './SearchFilterSuggestions'; const tokenEvents = ['Enter', 'Tab', ' ', ',']; @@ -193,10 +161,10 @@ export const SearchFilter: FC = () => { - Organization (org:orgname) + Organization (org:name) - Repository (repo:reponame) + Repository (repo:fullname) @@ -221,10 +189,7 @@ export const SearchFilter: FC = () => { { addIncludeSearchToken(e); setShowIncludeSuggestions(false); @@ -254,53 +219,11 @@ export const SearchFilter: FC = () => { title="Include searches" tokens={includeSearchTokens} /> - {showIncludeSuggestions && ( - setShowIncludeSuggestions(false)} - open - > - - - {QUALIFIERS.filter( - (q) => - q.key.startsWith(includeInputValue.toLowerCase()) || - includeInputValue === '', - ).map((q) => ( - { - setIncludeInputValue(`${q.key}:`); - const inputEl = - document.querySelector( - `fieldset#${fieldsetId} input[title='Include searches']`, - ); - if (inputEl) { - inputEl.value = `${q.key}:`; - inputEl.focus(); - } - setShowIncludeSuggestions(false); - }} - > - - {q.key} - - {q.description} - - - {getExample(q.key, 'include')} - - - - ))} - - - - )} + setShowIncludeSuggestions(false)} + open={showIncludeSuggestions} + /> @@ -319,10 +242,7 @@ export const SearchFilter: FC = () => { { addExcludeSearchToken(e); setShowExcludeSuggestions(false); @@ -351,53 +271,11 @@ export const SearchFilter: FC = () => { title="Exclude searches" tokens={excludeSearchTokens} /> - {showExcludeSuggestions && ( - setShowExcludeSuggestions(false)} - open - > - - - {QUALIFIERS.filter( - (q) => - q.key.startsWith(excludeInputValue.toLowerCase()) || - excludeInputValue === '', - ).map((q) => ( - { - setExcludeInputValue(`${q.key}:`); - const inputEl = - document.querySelector( - `fieldset#${fieldsetId} input[title='Exclude searches']`, - ); - if (inputEl) { - inputEl.value = `${q.key}:`; - inputEl.focus(); - } - setShowExcludeSuggestions(false); - }} - > - - {q.key} - - {q.description} - - - {getExample(q.key, 'exclude')} - - - - ))} - - - - )} + setShowExcludeSuggestions(false)} + open={showExcludeSuggestions} + />
diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx new file mode 100644 index 000000000..42788053c --- /dev/null +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -0,0 +1,42 @@ +import type { FC } from 'react'; + +import { ActionList, Popover, Text } from '@primer/react'; + +import { SEARCH_QUALIFIERS } from '../../utils/notifications/filters/search'; + +const QUALIFIERS = Object.values(SEARCH_QUALIFIERS); + +interface SearchFilterSuggestionsProps { + open: boolean; + inputValue: string; + onClose: () => void; +} + +export const SearchFilterSuggestions: FC = ({ + open, + inputValue, + onClose, +}) => { + if (!open) return null; + + return ( + + + + {QUALIFIERS.filter( + (q) => + q.prefix.startsWith(inputValue.toLowerCase()) || + inputValue === '', + ).map((q) => ( + + {q.prefix} + + {q.description} + + + ))} + + + + ); +}; diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index ad0dc8814..2edc5faa6 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,10 +5,10 @@ import type { SubjectUser, } from '../../../typesGitHub'; import { + AUTHOR_PREFIX, filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, - isAuthorToken, reasonFilter, stateFilter, subjectTypeFilter, @@ -91,8 +91,9 @@ function passesUserFilters( // Apply user-specific actor include filters (user: prefix) during detailed filtering if (hasIncludeSearchFilters(settings)) { - const userIncludeTokens = - settings.filterIncludeSearchTokens.filter(isAuthorToken); + const userIncludeTokens = settings.filterIncludeSearchTokens.filter((t) => + t.startsWith(AUTHOR_PREFIX), + ); if (userIncludeTokens.length > 0) { passesFilters = passesFilters && @@ -103,8 +104,9 @@ function passesUserFilters( } if (hasExcludeSearchFilters(settings)) { - const userExcludeTokens = - settings.filterExcludeSearchTokens.filter(isAuthorToken); + const userExcludeTokens = settings.filterExcludeSearchTokens.filter((t) => + t.startsWith(AUTHOR_PREFIX), + ); if (userExcludeTokens.length > 0) { passesFilters = passesFilters && diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 6612a90d5..36391004a 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -1,11 +1,23 @@ import type { SettingsState } from '../../../types'; import type { Notification } from '../../../typesGitHub'; -const AUTHOR_PREFIX = 'author:'; -const ORG_PREFIX = 'org:'; -const REPO_PREFIX = 'repo:'; +export const SEARCH_QUALIFIERS = { + author: { prefix: 'author:', description: 'filter by notification author' }, + org: { prefix: 'org:', description: 'filter by organization owner' }, + repo: { prefix: 'repo:', description: 'filter by repository full name' }, +} as const; -export const SEARCH_PREFIXES = [AUTHOR_PREFIX, ORG_PREFIX, REPO_PREFIX]; +export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; // 'author' | 'org' | 'repo' +export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; +export type SearchPrefix = SearchQualifier['prefix']; + +export const SEARCH_PREFIXES: readonly SearchPrefix[] = Object.values( + SEARCH_QUALIFIERS, +).map((q) => q.prefix) as readonly SearchPrefix[]; + +export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; +export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; +export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix; export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; @@ -15,16 +27,12 @@ export function hasExcludeSearchFilters(settings: SettingsState) { return settings.filterExcludeSearchTokens.length > 0; } -export function isAuthorToken(token: string) { - return token.startsWith(AUTHOR_PREFIX); -} - -export function isOrgToken(token: string) { - return token.startsWith(ORG_PREFIX); -} - -export function isRepoToken(token: string) { - return token.startsWith(REPO_PREFIX); +export function matchQualifierByPrefix(token: string) { + const prefix = SEARCH_PREFIXES.find((p) => token.startsWith(p)); + if (!prefix) return null; + return ( + Object.values(SEARCH_QUALIFIERS).find((q) => q.prefix === prefix) || null + ); } function stripPrefix(token: string) { @@ -35,18 +43,19 @@ export function filterNotificationBySearchTerm( notification: Notification, token: string, ): boolean { - if (isAuthorToken(token)) { + const qualifier = matchQualifierByPrefix(token); + if (!qualifier) return false; + + if (qualifier === SEARCH_QUALIFIERS.author) { const handle = stripPrefix(token); return notification.subject?.user?.login === handle; } - - if (isOrgToken(token)) { + if (qualifier === SEARCH_QUALIFIERS.org) { const org = stripPrefix(token); const owner = notification.repository?.owner?.login; return owner?.toLowerCase() === org.toLowerCase(); } - - if (isRepoToken(token)) { + if (qualifier === SEARCH_QUALIFIERS.repo) { const repo = stripPrefix(token); const name = notification.repository?.full_name; return name?.toLowerCase() === repo.toLowerCase(); @@ -55,9 +64,6 @@ export function filterNotificationBySearchTerm( return false; } -export function buildSearchToken( - type: 'author' | 'org' | 'repo', - value: string, -) { - return `${type}:${value}`; +export function buildSearchToken(type: SearchQualifierKey, value: string) { + return `${SEARCH_QUALIFIERS[type].prefix}${value}`; } From 173e525c5f65c27d0d7815f5726d603a02374ee9 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 09:59:03 -0400 Subject: [PATCH 06/26] feat: search notifications Signed-off-by: Adam Setch --- src/renderer/__mocks__/partial-mocks.ts | 4 +- .../components/filters/SearchFilter.test.tsx | 162 ++++- .../filters/SearchFilterSuggestions.tsx | 24 +- src/renderer/context/App.test.tsx | 6 +- .../__snapshots__/Filters.test.tsx.snap | 605 +++--------------- .../notifications/filters/filter.test.ts | 225 +++---- .../utils/notifications/filters/filter.ts | 77 +-- .../notifications/filters/search.test.ts | 150 +++++ .../utils/notifications/filters/search.ts | 47 +- 9 files changed, 566 insertions(+), 734 deletions(-) create mode 100644 src/renderer/utils/notifications/filters/search.test.ts 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/components/filters/SearchFilter.test.tsx b/src/renderer/components/filters/SearchFilter.test.tsx index 49823b5f1..2299ce16c 100644 --- a/src/renderer/components/filters/SearchFilter.test.tsx +++ b/src/renderer/components/filters/SearchFilter.test.tsx @@ -11,21 +11,151 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { jest.clearAllMocks(); }); - it('adds include actor token with prefix', () => { - render( - - - , - ); - - const includeInput = screen.getByTitle('Include actors'); - fireEvent.change(includeInput, { target: { value: 'user:octocat' } }); - fireEvent.keyDown(includeInput, { key: 'Enter' }); - - expect(updateFilter).toHaveBeenCalledWith( - 'filterIncludeActors', - 'user:octocat', - true, - ); + 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/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 42788053c..cfc9d8b77 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -1,7 +1,9 @@ import type { FC } from 'react'; -import { ActionList, Popover, Text } from '@primer/react'; +import { Box, Popover, Stack, Text } from '@primer/react'; +import { Opacity } from '../../types'; +import { cn } from '../../utils/cn'; import { SEARCH_QUALIFIERS } from '../../utils/notifications/filters/search'; const QUALIFIERS = Object.values(SEARCH_QUALIFIERS); @@ -21,21 +23,23 @@ export const SearchFilterSuggestions: FC = ({ return ( - - + + {QUALIFIERS.filter( (q) => q.prefix.startsWith(inputValue.toLowerCase()) || inputValue === '', ).map((q) => ( - - {q.prefix} - - {q.description} - - + + + {q.prefix} + + {q.description} + + + ))} - + ); diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index ba15d3a0e..94406859a 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -353,9 +353,11 @@ describe('renderer/context/App.tsx', () => { } as AuthState, settings: { ...mockSettings, + filterIncludeSearchTokens: defaultSettings.filterIncludeSearchTokens, + filterExcludeSearchTokens: defaultSettings.filterExcludeSearchTokens, filterUserTypes: defaultSettings.filterUserTypes, - filterIncludeActors: defaultSettings.filterIncludeSearchTokens, - filterExcludeActors: defaultSettings.filterExcludeSearchTokens, + filterSubjectTypes: defaultSettings.filterSubjectTypes, + filterStates: defaultSettings.filterStates, filterReasons: defaultSettings.filterReasons, }, }); diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index 3e5fbae24..256318339 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -100,32 +100,51 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children
-
- + +
- -
-
-
+
- -
- + > + +
-
- + +
- -
- + > + +
-
- + +
@@ -504,437 +504,6 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children
- -
-
- -

- Handles -

-
- -
-
-
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
- -
-
- -

- Organizations -

-
- -
-
-
-
-
-
-
-
- - - Include: - -
-
- -
-
- -
-
-
-
-
-
-
- - - Exclude: - -
-
- -
-
- -
-
-
-
-
-
-
>>>>>> origin/main id="filter-subject-type" > diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 71a184c76..d04ff242a 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -2,12 +2,13 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; import { defaultSettings } from '../../../context/defaults'; import type { Link, SearchToken, SettingsState } from '../../../types'; -import type { Repository } from '../../../typesGitHub'; +import type { Owner } from '../../../typesGitHub'; import { filterBaseNotifications, filterDetailedNotifications, hasAnyFiltersSet, } from './filter'; +import { AUTHOR_PREFIX, ORG_PREFIX, REPO_PREFIX } from './search'; describe('renderer/utils/notifications/filters/filter.ts', () => { afterEach(() => { @@ -16,26 +17,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', + }, + }, + { + 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', + }, }, - }), - 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', () => { @@ -62,178 +79,118 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); - }); - describe('filterDetailedNotifications', () => { - it('should ignore user type, handle filters and state filters if detailed notifications not enabled', async () => { - const result = filterDetailedNotifications(mockNotifications, { + it('should filter notifications that match include organization', async () => { + const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - detailedNotifications: false, - filterUserTypes: ['Bot'], - filterIncludeSearchTokens: ['author:github-user' as SearchToken], - filterExcludeSearchTokens: ['author:github-bot' as SearchToken], - filterStates: ['merged'], + filterIncludeSearchTokens: [`${ORG_PREFIX}gitify-app` as SearchToken], }); - expect(result.length).toBe(2); - expect(result).toEqual(mockNotifications); + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications by user type provided', async () => { - const result = filterDetailedNotifications(mockNotifications, { + it('should filter notifications that match exclude organization', async () => { + const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - detailedNotifications: true, - filterUserTypes: ['Bot'], + filterExcludeSearchTokens: [`${ORG_PREFIX}github` as SearchToken], }); expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[1]]); + expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications that match include author handle', async () => { - const result = filterDetailedNotifications(mockNotifications, { + it('should filter notifications that match include repository', async () => { + const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - detailedNotifications: true, - filterIncludeSearchTokens: ['author:github-user' as SearchToken], + filterIncludeSearchTokens: [ + `${REPO_PREFIX}gitify-app/gitify` as SearchToken, + ], }); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications that match exclude author handle', async () => { - const result = filterDetailedNotifications(mockNotifications, { + it('should filter notifications that match exclude repository', async () => { + const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - detailedNotifications: true, - filterExcludeSearchTokens: ['author:github-bot' as SearchToken], + filterExcludeSearchTokens: [ + `${REPO_PREFIX}github/github` as SearchToken, + ], }); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); + }); - it('should filter notifications by state when provided', async () => { - mockNotifications[0].subject.state = 'open'; - mockNotifications[1].subject.state = 'closed'; + describe('filterDetailedNotifications', () => { + it('should ignore user type, handle filters and state filters if detailed notifications not enabled', async () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, - filterStates: ['closed'], + detailedNotifications: false, + filterUserTypes: ['Bot'], + filterIncludeSearchTokens: [ + `${AUTHOR_PREFIX}github-user` as SearchToken, + ], + filterExcludeSearchTokens: [ + `${AUTHOR_PREFIX}github-bot` as SearchToken, + ], + filterStates: ['merged'], }); - expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[1]]); + expect(result.length).toBe(2); + expect(result).toEqual(mockNotifications); }); - it('should filter notifications that match include organization', async () => { - mockNotifications[1].repository = { - owner: { - login: 'gitify-app', - }, - } as Repository; - - mockNotifications[1].repository = { - owner: { - login: 'github', - }, - } as Repository; - - // Apply base filtering first (where organization filtering now happens) - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], - }); - - // Then apply detailed filtering - result = filterDetailedNotifications(result, { + it('should filter notifications by user type provided', async () => { + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], + filterUserTypes: ['Bot'], }); expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[0]]); + expect(result).toEqual([mockNotifications[1]]); }); - it('should filter notifications that match exclude organization', async () => { - mockNotifications[1].repository = { - owner: { - login: 'gitify-app', - }, - } as Repository; - - mockNotifications[1].repository = { - owner: { - login: 'github', - }, - } as Repository; - - // Apply base filtering first (where organization filtering now happens) - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterExcludeSearchTokens: ['org:github' as SearchToken], - }); - - // Then apply detailed filtering - result = filterDetailedNotifications(result, { + it('should filter notifications that match include author handle', async () => { + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterExcludeSearchTokens: ['org:github' as SearchToken], + filterIncludeSearchTokens: [ + `${AUTHOR_PREFIX}github-user` as SearchToken, + ], }); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications that match include repository', async () => { - mockNotifications[1].repository = { - full_name: 'gitify-app/gitify', - } as Repository; - - mockNotifications[1].repository = { - full_name: 'other/other', - } as Repository; - - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], - }); - - result = filterDetailedNotifications(result, { + it('should filter notifications that match exclude author handle', async () => { + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], + filterExcludeSearchTokens: [ + `${AUTHOR_PREFIX}github-bot` as SearchToken, + ], }); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); - it('should filter notifications that match exclude repository', async () => { - mockNotifications[1].repository = { - id: 1, - name: 'gitify', - full_name: 'gitify-app/gitify', - } as Repository; - - mockNotifications[1].repository = { - id: 2, - name: 'other', - full_name: 'other/other', - } as Repository; - - let result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterExcludeSearchTokens: ['repo:other/other' as SearchToken], - }); - - result = filterDetailedNotifications(result, { + it('should filter notifications by state when provided', async () => { + mockNotifications[0].subject.state = 'open'; + mockNotifications[1].subject.state = 'closed'; + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, - detailedNotifications: true, - filterExcludeSearchTokens: ['repo:other/other' as SearchToken], + filterStates: ['closed'], }); expect(result.length).toBe(1); - expect(result).toEqual([mockNotifications[0]]); + expect(result).toEqual([mockNotifications[1]]); }); }); }); @@ -246,7 +203,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default search token includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeSearchTokens: ['author:gitify' as SearchToken], + filterIncludeSearchTokens: [`${AUTHOR_PREFIX}gitify` as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); @@ -254,7 +211,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default search token excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeSearchTokens: ['org:github' as SearchToken], + filterExcludeSearchTokens: [`${ORG_PREFIX}github` as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index 2edc5faa6..f532f11b8 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -6,6 +6,9 @@ import type { } from '../../../typesGitHub'; import { AUTHOR_PREFIX, + ORG_PREFIX, + REPO_PREFIX, + type SearchPrefix, filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, @@ -22,8 +25,16 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; - passesFilters = - passesFilters && passesActorIncludeExcludeFilters(notification, settings); + + passesFilters = + passesFilters && + passesSearchTokenFiltersForPrefix(notification, settings, ORG_PREFIX); + + passesFilters = + passesFilters && + passesSearchTokenFiltersForPrefix(notification, settings, REPO_PREFIX); + + if (subjectTypeFilter.hasFilters(settings)) { passesFilters = @@ -75,71 +86,63 @@ export function hasAnyFiltersSet(settings: SettingsState): boolean { ); } -function passesUserFilters( +/** + * Apply include/exclude search token logic for a specific search qualifier prefix. + */ +function passesSearchTokenFiltersForPrefix( notification: Notification, settings: SettingsState, + prefix: SearchPrefix, ): boolean { - let passesFilters = true; - - if (userTypeFilter.hasFilters(settings)) { - passesFilters = - passesFilters && - settings.filterUserTypes.some((userType) => - userTypeFilter.filterNotification(notification, userType), - ); - } + let passes = true; - // Apply user-specific actor include filters (user: prefix) during detailed filtering if (hasIncludeSearchFilters(settings)) { - const userIncludeTokens = settings.filterIncludeSearchTokens.filter((t) => - t.startsWith(AUTHOR_PREFIX), + const includeTokens = settings.filterIncludeSearchTokens.filter((t) => + t.startsWith(prefix), ); - if (userIncludeTokens.length > 0) { - passesFilters = - passesFilters && - userIncludeTokens.some((token) => + if (includeTokens.length > 0) { + passes = + passes && + includeTokens.some((token) => filterNotificationBySearchTerm(notification, token), ); } } if (hasExcludeSearchFilters(settings)) { - const userExcludeTokens = settings.filterExcludeSearchTokens.filter((t) => - t.startsWith(AUTHOR_PREFIX), + const excludeTokens = settings.filterExcludeSearchTokens.filter((t) => + t.startsWith(prefix), ); - if (userExcludeTokens.length > 0) { - passesFilters = - passesFilters && - !userExcludeTokens.some((token) => + if (excludeTokens.length > 0) { + passes = + passes && + !excludeTokens.some((token) => filterNotificationBySearchTerm(notification, token), ); } } - return passesFilters; + return passes; } -function passesActorIncludeExcludeFilters( +function passesUserFilters( notification: Notification, settings: SettingsState, ): boolean { let passesFilters = true; - if (hasIncludeSearchFilters(settings)) { + if (userTypeFilter.hasFilters(settings)) { passesFilters = passesFilters && - settings.filterIncludeSearchTokens.some((token) => - filterNotificationBySearchTerm(notification, token), + settings.filterUserTypes.some((userType) => + userTypeFilter.filterNotification(notification, userType), ); } - if (hasExcludeSearchFilters(settings)) { - passesFilters = - passesFilters && - !settings.filterExcludeSearchTokens.some((token) => - filterNotificationBySearchTerm(notification, token), - ); - } + // Apply author-specific search token filters during detailed filtering + passesFilters = + passesFilters && + passesSearchTokenFiltersForPrefix(notification, settings, AUTHOR_PREFIX); return passesFilters; } 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..89554e2be --- /dev/null +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -0,0 +1,150 @@ +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import type { Link } from '../../../types'; +import type { Owner } from '../../../typesGitHub'; +import { + AUTHOR_PREFIX, + filterNotificationBySearchTerm, + matchQualifierByPrefix, + ORG_PREFIX, + REPO_PREFIX, + SEARCH_PREFIXES, + SEARCH_QUALIFIERS, +} from './search'; + +// (helper removed – no longer used) + +describe('renderer/utils/notifications/filters/search.ts', () => { + describe('matchQualifierByPrefix', () => { + it('returns null for empty string', () => { + expect(matchQualifierByPrefix('')).toBeNull(); + }); + + it('returns null when no qualifier prefix matches', () => { + expect(matchQualifierByPrefix('unknown:value')).toBeNull(); + expect(matchQualifierByPrefix('auth:foo')).toBeNull(); // near miss + }); + + it('matches each known qualifier by its exact prefix and additional value', () => { + for (const prefix of SEARCH_PREFIXES) { + const token = prefix + 'someValue'; + const qualifier = matchQualifierByPrefix(token); + expect(qualifier).not.toBeNull(); + if (qualifier) { + const found = Object.values(SEARCH_QUALIFIERS).find( + (q) => q.prefix === prefix, + ); + expect(qualifier).toBe(found); + } + } + }); + + it('is case-sensitive (does not match mismatched casing)', () => { + // Intentionally alter case of prefix characters + expect(matchQualifierByPrefix('Author:foo')).toBeNull(); + expect(matchQualifierByPrefix('ORG:bar')).toBeNull(); + expect(matchQualifierByPrefix('Repo:baz')).toBeNull(); + }); + + it('does not match when prefix appears later in the token', () => { + expect(matchQualifierByPrefix('xauthor:foo')).toBeNull(); + expect(matchQualifierByPrefix('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_PREFIX}github-user`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + `${AUTHOR_PREFIX}GITHUB-USER`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + `${AUTHOR_PREFIX}some-bot`, + ), + ).toBe(false); + }); + + it('matches org qualifier (case-insensitive)', () => { + expect( + filterNotificationBySearchTerm( + mockNotification, + `${ORG_PREFIX}gitify-app`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + `${ORG_PREFIX}GITIFY-APP`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm(mockNotification, `${ORG_PREFIX}github`), + ).toBe(false); + }); + + it('matches repo qualifier (case-insensitive full_name)', () => { + expect( + filterNotificationBySearchTerm( + mockNotification, + `${REPO_PREFIX}gitify-app/gitify`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + `${REPO_PREFIX}Gitify-App/Gitify`, + ), + ).toBe(true); + + expect( + filterNotificationBySearchTerm( + mockNotification, + `${REPO_PREFIX}github/other`, + ), + ).toBe(false); + }); + + it('returns false for unknown qualifier', () => { + expect( + filterNotificationBySearchTerm(mockNotification, 'unknown:thing'), + ).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 index 36391004a..4840ea78c 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -2,12 +2,24 @@ import type { SettingsState } from '../../../types'; import type { Notification } from '../../../typesGitHub'; export const SEARCH_QUALIFIERS = { - author: { prefix: 'author:', description: 'filter by notification author' }, - org: { prefix: 'org:', description: 'filter by organization owner' }, - repo: { prefix: 'repo:', description: 'filter by repository full name' }, + author: { + prefix: 'author:', + description: 'filter by notification author', + requiresDetailsNotifications: true, + }, + org: { + prefix: 'org:', + description: 'filter by organization owner', + requiresDetailsNotifications: false, + }, + repo: { + prefix: 'repo:', + description: 'filter by repository full name', + requiresDetailsNotifications: false, + }, } as const; -export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; // 'author' | 'org' | 'repo' +export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; export type SearchPrefix = SearchQualifier['prefix']; @@ -29,7 +41,10 @@ export function hasExcludeSearchFilters(settings: SettingsState) { export function matchQualifierByPrefix(token: string) { const prefix = SEARCH_PREFIXES.find((p) => token.startsWith(p)); - if (!prefix) return null; + if (!prefix) { + return null; + } + return ( Object.values(SEARCH_QUALIFIERS).find((q) => q.prefix === prefix) || null ); @@ -44,26 +59,26 @@ export function filterNotificationBySearchTerm( token: string, ): boolean { const qualifier = matchQualifierByPrefix(token); - if (!qualifier) return false; + if (!qualifier) { + return false; + } + + const value = stripPrefix(token); if (qualifier === SEARCH_QUALIFIERS.author) { - const handle = stripPrefix(token); - return notification.subject?.user?.login === handle; + const author = notification.subject?.user?.login; + return author.toLowerCase() === value.toLowerCase(); } + if (qualifier === SEARCH_QUALIFIERS.org) { - const org = stripPrefix(token); const owner = notification.repository?.owner?.login; - return owner?.toLowerCase() === org.toLowerCase(); + return owner?.toLowerCase() === value.toLowerCase(); } + if (qualifier === SEARCH_QUALIFIERS.repo) { - const repo = stripPrefix(token); const name = notification.repository?.full_name; - return name?.toLowerCase() === repo.toLowerCase(); + return name?.toLowerCase() === value.toLowerCase(); } return false; } - -export function buildSearchToken(type: SearchQualifierKey, value: string) { - return `${SEARCH_QUALIFIERS[type].prefix}${value}`; -} From 699208c78a12bcb844db2939b5c65b64978a2aaf Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 10:07:00 -0400 Subject: [PATCH 07/26] feat: search notifications Signed-off-by: Adam Setch --- .../utils/notifications/filters/filter.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index f532f11b8..d7006e7b4 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -6,13 +6,13 @@ import type { } from '../../../typesGitHub'; import { AUTHOR_PREFIX, - ORG_PREFIX, - REPO_PREFIX, - type SearchPrefix, filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, + ORG_PREFIX, + REPO_PREFIX, reasonFilter, + type SearchPrefix, stateFilter, subjectTypeFilter, userTypeFilter, @@ -25,16 +25,13 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; + passesFilters = + passesFilters && + passesSearchTokenFiltersForPrefix(notification, settings, ORG_PREFIX); - passesFilters = - passesFilters && - passesSearchTokenFiltersForPrefix(notification, settings, ORG_PREFIX); - - passesFilters = - passesFilters && - passesSearchTokenFiltersForPrefix(notification, settings, REPO_PREFIX); - - + passesFilters = + passesFilters && + passesSearchTokenFiltersForPrefix(notification, settings, REPO_PREFIX); if (subjectTypeFilter.hasFilters(settings)) { passesFilters = From e0b1568d4d74b06c436e31cd6c82c6ee402037b7 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 10:31:57 -0400 Subject: [PATCH 08/26] feat: search notifications Signed-off-by: Adam Setch --- .../utils/notifications/filters/search.test.ts | 6 ++++++ src/renderer/utils/notifications/filters/search.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index 89554e2be..729dacc5e 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -143,6 +143,12 @@ describe('renderer/utils/notifications/filters/search.ts', () => { ).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 index 4840ea78c..a195c2cf3 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -51,7 +51,7 @@ export function matchQualifierByPrefix(token: string) { } function stripPrefix(token: string) { - return token.substring(token.indexOf(':') + 1); + return token.substring(token.indexOf(':') + 1).trim(); } export function filterNotificationBySearchTerm( @@ -64,20 +64,25 @@ export function filterNotificationBySearchTerm( } const value = stripPrefix(token); + const valueLower = value.toLowerCase(); + + if (valueLower.length === 0) { + return false; + } if (qualifier === SEARCH_QUALIFIERS.author) { const author = notification.subject?.user?.login; - return author.toLowerCase() === value.toLowerCase(); + return author?.toLowerCase() === valueLower; } if (qualifier === SEARCH_QUALIFIERS.org) { const owner = notification.repository?.owner?.login; - return owner?.toLowerCase() === value.toLowerCase(); + return owner?.toLowerCase() === valueLower; } if (qualifier === SEARCH_QUALIFIERS.repo) { const name = notification.repository?.full_name; - return name?.toLowerCase() === value.toLowerCase(); + return name?.toLowerCase() === valueLower; } return false; From 7ef4870e3e52424e5d32264107364472f9e02e50 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 11:25:13 -0400 Subject: [PATCH 09/26] feat: search notifications Signed-off-by: Adam Setch --- .../components/filters/SearchFilter.tsx | 242 ++++-------------- .../filters/SearchFilterSuggestions.tsx | 4 +- .../components/filters/TokenSearchInput.tsx | 112 ++++++++ .../__snapshots__/Filters.test.tsx.snap | 6 +- .../utils/notifications/filters/search.ts | 88 ++++--- 5 files changed, 217 insertions(+), 235 deletions(-) create mode 100644 src/renderer/components/filters/TokenSearchInput.tsx diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index bb293069c..ea6a80876 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -8,37 +8,18 @@ import { RepoIcon, SearchIcon, } from '@primer/octicons-react'; -import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; +import { Box, Stack, Text } from '@primer/react'; import { AppContext } from '../../context/App'; import { IconColor, type SearchToken, Size } from '../../types'; -import { - hasExcludeSearchFilters, - hasIncludeSearchFilters, - SEARCH_PREFIXES, -} from '../../utils/notifications/filters/search'; +import { hasExcludeSearchFilters, hasIncludeSearchFilters } from '../../utils/notifications/filters/search'; import { Title } from '../primitives/Title'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; +import { TokenSearchInput } from './TokenSearchInput'; +import { cn } from '../../utils/cn'; type InputToken = { id: number; text: string }; -import { SearchFilterSuggestions } from './SearchFilterSuggestions'; - -const tokenEvents = ['Enter', 'Tab', ' ', ',']; - -function parseRawValue(raw: string): string | null { - const value = raw.trim(); - if (!value) return null; - // Find a matching prefix (prefixes already include the colon) - const matched = SEARCH_PREFIXES.find((p) => - value.toLowerCase().startsWith(p), - ); - if (!matched) return null; - const rest = value.substring(matched.length); - if (rest.length === 0) return null; - return `${matched}${rest}`; // matched already has ':' -} - export const SearchFilter: FC = () => { const { updateFilter, settings } = useContext(AppContext); @@ -56,89 +37,39 @@ export const SearchFilter: FC = () => { const mapValuesToTokens = (values: string[]): InputToken[] => values.map((value, index) => ({ id: index, text: value })); - const [includeSearchTokens, setIncludeSearchTokens] = useState( - mapValuesToTokens(settings.filterIncludeSearchTokens), - ); - - const addIncludeSearchToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const raw = (event.target as HTMLInputElement).value; - const value = parseRawValue(raw); + const [includeSearchTokens, setIncludeSearchTokens] = useState(mapValuesToTokens(settings.filterIncludeSearchTokens)); - if (value && !includeSearchTokens.some((v) => v.text === value)) { - setIncludeSearchTokens([ - ...includeSearchTokens, - { id: includeSearchTokens.length, text: value }, - ]); - updateFilter('filterIncludeSearchTokens', value as SearchToken, true); - (event.target as HTMLInputElement).value = ''; - } + const addIncludeSearchToken = (value: string) => { + if (!value || includeSearchTokens.some((v) => v.text === value)) return; + const nextId = includeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; + setIncludeSearchTokens([...includeSearchTokens, { id: nextId, text: value }]); + updateFilter('filterIncludeSearchTokens', value as SearchToken, true); }; const removeIncludeSearchToken = (tokenId: string | number) => { const value = includeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterIncludeSearchTokens', value as SearchToken, false); + if (value) updateFilter('filterIncludeSearchTokens', value as SearchToken, false); setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId)); }; - const [includeInputValue, setIncludeInputValue] = useState(''); - const [showIncludeSuggestions, setShowIncludeSuggestions] = useState(false); - - const includeSearchTokensKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addIncludeSearchToken(event); - setShowIncludeSuggestions(false); - } else if (event.key === 'ArrowDown') { - setShowIncludeSuggestions(true); - } - }; - - const [excludeSearchTokens, setExcludeSearchTokens] = useState( - mapValuesToTokens(settings.filterExcludeSearchTokens), - ); + // now handled inside TokenSearchInput - const addExcludeSearchToken = ( - event: - | React.KeyboardEvent - | React.FocusEvent, - ) => { - const raw = (event.target as HTMLInputElement).value; - const value = parseRawValue(raw); + const [excludeSearchTokens, setExcludeSearchTokens] = useState(mapValuesToTokens(settings.filterExcludeSearchTokens)); - if (value && !excludeSearchTokens.some((v) => v.text === value)) { - setExcludeSearchTokens([ - ...excludeSearchTokens, - { id: excludeSearchTokens.length, text: value }, - ]); - updateFilter('filterExcludeSearchTokens', value as SearchToken, true); - (event.target as HTMLInputElement).value = ''; - } + const addExcludeSearchToken = (value: string) => { + if (!value || excludeSearchTokens.some((v) => v.text === value)) return; + const nextId = excludeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; + setExcludeSearchTokens([...excludeSearchTokens, { id: nextId, text: value }]); + updateFilter('filterExcludeSearchTokens', value as SearchToken, true); }; const removeExcludeSearchToken = (tokenId: string | number) => { const value = excludeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - updateFilter('filterExcludeSearchTokens', value as SearchToken, false); + if (value) updateFilter('filterExcludeSearchTokens', value as SearchToken, false); setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId)); }; - const [excludeInputValue, setExcludeInputValue] = useState(''); - const [showExcludeSuggestions, setShowExcludeSuggestions] = useState(false); - - const excludeSearchTokensKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (tokenEvents.includes(event.key)) { - addExcludeSearchToken(event); - setShowExcludeSuggestions(false); - } else if (event.key === 'ArrowDown') { - setShowExcludeSuggestions(true); - } - }; + // handled by TokenSearchInput // Basic suggestions for prefixes const fieldsetId = useId(); @@ -154,14 +85,15 @@ export const SearchFilter: FC = () => { - Author (author:handle) + Author (author:handle) - Organization (org:name) + Organization (org:name) - Repository (repo:fullname) + + Repository (repo:fullname) @@ -173,110 +105,28 @@ export const SearchFilter: FC = () => { - - - - - Include: - - - - { - addIncludeSearchToken(e); - setShowIncludeSuggestions(false); - }} - onChange={(e: React.ChangeEvent) => { - setIncludeInputValue(e.target.value); - // Show suggestions once user starts typing or clears until prefix chosen - const val = e.target.value.trim(); - if (!val.includes(':')) { - setShowIncludeSuggestions(true); - } else { - setShowIncludeSuggestions(false); - } - }} - onFocus={(e) => { - if ( - !hasExcludeSearchFilters(settings) && - !!settings.detailedNotifications && - (e.target as HTMLInputElement).value.trim() === '' - ) { - setShowIncludeSuggestions(true); - } - }} - onKeyDown={includeSearchTokensKeyDown} - onTokenRemove={removeIncludeSearchToken} - size="small" - title="Include searches" - tokens={includeSearchTokens} - /> - setShowIncludeSuggestions(false)} - open={showIncludeSuggestions} - /> - - - - - - - - Exclude: - - - - { - addExcludeSearchToken(e); - setShowExcludeSuggestions(false); - }} - onChange={(e: React.ChangeEvent) => { - setExcludeInputValue(e.target.value); - const val = e.target.value.trim(); - if (!val.includes(':')) { - setShowExcludeSuggestions(true); - } else { - setShowExcludeSuggestions(false); - } - }} - onFocus={(e) => { - if ( - !hasIncludeSearchFilters(settings) && - !!settings.detailedNotifications && - (e.target as HTMLInputElement).value.trim() === '' - ) { - setShowExcludeSuggestions(true); - } - }} - onKeyDown={excludeSearchTokensKeyDown} - onTokenRemove={removeExcludeSearchToken} - size="small" - title="Exclude searches" - tokens={excludeSearchTokens} - /> - setShowExcludeSuggestions(false)} - open={showExcludeSuggestions} - /> - - + +
); diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index cfc9d8b77..01b9cb3cd 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -19,7 +19,9 @@ export const SearchFilterSuggestions: FC = ({ inputValue, onClose, }) => { - if (!open) return null; + if (!open) { + return null; + } return ( diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx new file mode 100644 index 000000000..17bf11622 --- /dev/null +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -0,0 +1,112 @@ +import { type FC, useState } from 'react'; + +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; + +import type { SearchToken } from '../../types'; +import { normalizeSearchInputToToken } from '../../utils/notifications/filters/search'; +import { SearchFilterSuggestions } from './SearchFilterSuggestions'; + +export interface TokenInputItem { + id: number; // stable index-based id (unique within its list) + text: string; // actual token string (e.g. "author:octocat") +} + +interface TokenSearchInputProps { + label: string; // "Include" | "Exclude" + icon: FC<{ className?: string }>; + iconColorClass: string; + tokens: TokenInputItem[]; + showSuggestionsOnFocusIfEmpty: boolean; // parent pre-computed condition + onAdd: (token: string) => void; + onRemove: (tokenId: string | number) => void; +} + +const tokenEvents = ['Enter', 'Tab', ' ', ',']; + +export const TokenSearchInput: FC = ({ + label, + icon: Icon, + iconColorClass, + tokens, + showSuggestionsOnFocusIfEmpty, + onAdd, + onRemove, +}) => { + const [inputValue, setInputValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + + function tryAddToken( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) { + const raw = (event.target as HTMLInputElement).value; + const value = normalizeSearchInputToToken(raw); + if (value && !tokens.some((t) => t.text === value)) { + onAdd(value as SearchToken); + (event.target as HTMLInputElement).value = ''; + setInputValue(''); + } + } + + function onKeyDown(e: React.KeyboardEvent) { + if (tokenEvents.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(':') || val.endsWith(':')) { + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + }} + onFocus={(e) => { + if ( + showSuggestionsOnFocusIfEmpty && + (e.target as HTMLInputElement).value.trim() === '' + ) { + setShowSuggestions(true); + } + }} + onKeyDown={onKeyDown} + onTokenRemove={onRemove} + size="small" + title={`${label} searches`} + tokens={tokens} + /> + setShowSuggestions(false)} + open={showSuggestions} + /> + + + ); +}; diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index 256318339..58ad4aaf3 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -211,7 +211,8 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children - Include: + Include + : @@ -286,7 +287,8 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children - Exclude: + Exclude + : diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index a195c2cf3..afad23ab1 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -6,16 +6,20 @@ export const SEARCH_QUALIFIERS = { prefix: 'author:', description: 'filter by notification author', requiresDetailsNotifications: true, + extract: (n: Notification) => n.subject?.user?.login as string | undefined, }, org: { prefix: 'org:', description: 'filter by organization owner', requiresDetailsNotifications: false, + extract: (n: Notification) => + n.repository?.owner?.login as string | undefined, }, repo: { prefix: 'repo:', description: 'filter by repository full name', requiresDetailsNotifications: false, + extract: (n: Notification) => n.repository?.full_name as string | undefined, }, } as const; @@ -27,6 +31,16 @@ export const SEARCH_PREFIXES: readonly SearchPrefix[] = Object.values( SEARCH_QUALIFIERS, ).map((q) => q.prefix) as readonly SearchPrefix[]; +// Map prefix -> qualifier for fast lookup after prefix detection +export const QUALIFIER_BY_PREFIX: Record = + Object.values(SEARCH_QUALIFIERS).reduce( + (acc, q) => { + acc[q.prefix as SearchPrefix] = q; + return acc; + }, + {} as Record, + ); + export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix; @@ -40,50 +54,52 @@ export function hasExcludeSearchFilters(settings: SettingsState) { } export function matchQualifierByPrefix(token: string) { - const prefix = SEARCH_PREFIXES.find((p) => token.startsWith(p)); - if (!prefix) { - return null; + // Iterate prefixes (tiny list) then direct map lookup; preserves ordering behavior + for (const prefix of SEARCH_PREFIXES) { + if (token.startsWith(prefix)) { + return QUALIFIER_BY_PREFIX[prefix] || null; + } } + return null; +} - return ( - Object.values(SEARCH_QUALIFIERS).find((q) => q.prefix === prefix) || null - ); +function stripPrefix(token: string, qualifier: SearchQualifier) { + return token.slice(qualifier.prefix.length).trim(); } -function stripPrefix(token: string) { - return token.substring(token.indexOf(':') + 1).trim(); +export interface ParsedSearchToken { + qualifier: SearchQualifier; + value: string; + valueLower: string; +} + +export function parseSearchToken(token: string): ParsedSearchToken | null { + const qualifier = matchQualifierByPrefix(token); + if (!qualifier) return null; + const value = stripPrefix(token, qualifier); + if (!value) return null; + return { qualifier, value, valueLower: value.toLowerCase() }; +} + +// Normalize raw user input from the token text field into a SearchToken (string) +// Returns null if no known prefix or no value after prefix yet. +export function normalizeSearchInputToToken(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + const lower = value.toLowerCase(); + const matched = SEARCH_PREFIXES.find((p) => lower.startsWith(p)); + if (!matched) return null; + const rest = value.substring(matched.length); + if (rest.length === 0) return null; // prefix only, incomplete token + return `${matched}${rest}`; // preserve original rest casing } export function filterNotificationBySearchTerm( notification: Notification, token: string, ): boolean { - const qualifier = matchQualifierByPrefix(token); - if (!qualifier) { - return false; - } - - const value = stripPrefix(token); - const valueLower = value.toLowerCase(); - - if (valueLower.length === 0) { - return false; - } - - if (qualifier === SEARCH_QUALIFIERS.author) { - const author = notification.subject?.user?.login; - return author?.toLowerCase() === valueLower; - } - - if (qualifier === SEARCH_QUALIFIERS.org) { - const owner = notification.repository?.owner?.login; - return owner?.toLowerCase() === valueLower; - } - - if (qualifier === SEARCH_QUALIFIERS.repo) { - const name = notification.repository?.full_name; - return name?.toLowerCase() === valueLower; - } - - return false; + const parsed = parseSearchToken(token); + if (!parsed) return false; + const fieldValue = parsed.qualifier.extract(notification); + return fieldValue?.toLowerCase() === parsed.valueLower; } From 4860d460689f1a5d1b9b893cb8e33f35e089c3b5 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 11:27:12 -0400 Subject: [PATCH 10/26] feat: search notifications Signed-off-by: Adam Setch --- src/renderer/components/filters/SearchFilter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index ea6a80876..1ca52c3eb 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -112,7 +112,7 @@ export const SearchFilter: FC = () => { onAdd={addIncludeSearchToken} onRemove={removeIncludeSearchToken} showSuggestionsOnFocusIfEmpty={ - !hasExcludeSearchFilters(settings) && !!settings.detailedNotifications + !hasIncludeSearchFilters(settings) } tokens={includeSearchTokens} /> @@ -123,7 +123,7 @@ export const SearchFilter: FC = () => { onAdd={addExcludeSearchToken} onRemove={removeExcludeSearchToken} showSuggestionsOnFocusIfEmpty={ - !hasIncludeSearchFilters(settings) && !!settings.detailedNotifications + !hasExcludeSearchFilters(settings) } tokens={excludeSearchTokens} /> From bb4d37776d7b1eb8ce750228b058c09bca0e2c7d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 11:38:15 -0400 Subject: [PATCH 11/26] feat: search notifications Signed-off-by: Adam Setch --- src/main/index.ts | 2 +- .../components/filters/SearchFilter.tsx | 58 ++++++++++++------- .../filters/SearchFilterSuggestions.tsx | 47 ++++++++++----- .../components/filters/TokenSearchInput.tsx | 10 +++- .../utils/notifications/filters/search.ts | 2 + 5 files changed, 82 insertions(+), 37 deletions(-) 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/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 1ca52c3eb..65cabab65 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -12,11 +12,14 @@ import { Box, Stack, Text } from '@primer/react'; import { AppContext } from '../../context/App'; import { IconColor, type SearchToken, Size } from '../../types'; -import { hasExcludeSearchFilters, hasIncludeSearchFilters } from '../../utils/notifications/filters/search'; +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'; -import { cn } from '../../utils/cn'; type InputToken = { id: number; text: string }; @@ -37,40 +40,50 @@ export const SearchFilter: FC = () => { const mapValuesToTokens = (values: string[]): InputToken[] => values.map((value, index) => ({ id: index, text: value })); - const [includeSearchTokens, setIncludeSearchTokens] = useState(mapValuesToTokens(settings.filterIncludeSearchTokens)); + const [includeSearchTokens, setIncludeSearchTokens] = useState( + mapValuesToTokens(settings.filterIncludeSearchTokens), + ); const addIncludeSearchToken = (value: string) => { if (!value || includeSearchTokens.some((v) => v.text === value)) return; - const nextId = includeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; - setIncludeSearchTokens([...includeSearchTokens, { id: nextId, text: value }]); + const nextId = + includeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; + setIncludeSearchTokens([ + ...includeSearchTokens, + { id: nextId, text: value }, + ]); updateFilter('filterIncludeSearchTokens', value as SearchToken, true); }; const removeIncludeSearchToken = (tokenId: string | number) => { const value = includeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - if (value) updateFilter('filterIncludeSearchTokens', value as SearchToken, false); + if (value) + updateFilter('filterIncludeSearchTokens', value as SearchToken, false); setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId)); }; - // now handled inside TokenSearchInput - - const [excludeSearchTokens, setExcludeSearchTokens] = useState(mapValuesToTokens(settings.filterExcludeSearchTokens)); + const [excludeSearchTokens, setExcludeSearchTokens] = useState( + mapValuesToTokens(settings.filterExcludeSearchTokens), + ); const addExcludeSearchToken = (value: string) => { if (!value || excludeSearchTokens.some((v) => v.text === value)) return; - const nextId = excludeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; - setExcludeSearchTokens([...excludeSearchTokens, { id: nextId, text: value }]); + const nextId = + excludeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; + setExcludeSearchTokens([ + ...excludeSearchTokens, + { id: nextId, text: value }, + ]); updateFilter('filterExcludeSearchTokens', value as SearchToken, true); }; const removeExcludeSearchToken = (tokenId: string | number) => { const value = excludeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - if (value) updateFilter('filterExcludeSearchTokens', value as SearchToken, false); + if (value) + updateFilter('filterExcludeSearchTokens', value as SearchToken, false); setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId)); }; - // handled by TokenSearchInput - // Basic suggestions for prefixes const fieldsetId = useId(); @@ -85,7 +98,14 @@ export const SearchFilter: FC = () => { - Author (author:handle) + + Author (author:handle) + @@ -111,9 +131,7 @@ export const SearchFilter: FC = () => { label="Include" onAdd={addIncludeSearchToken} onRemove={removeIncludeSearchToken} - showSuggestionsOnFocusIfEmpty={ - !hasIncludeSearchFilters(settings) - } + showSuggestionsOnFocusIfEmpty={!hasIncludeSearchFilters(settings)} tokens={includeSearchTokens} /> { label="Exclude" onAdd={addExcludeSearchToken} onRemove={removeExcludeSearchToken} - showSuggestionsOnFocusIfEmpty={ - !hasExcludeSearchFilters(settings) - } + showSuggestionsOnFocusIfEmpty={!hasExcludeSearchFilters(settings)} tokens={excludeSearchTokens} /> diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 01b9cb3cd..20b0bfcec 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -4,7 +4,10 @@ import { Box, Popover, Stack, Text } from '@primer/react'; import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; -import { SEARCH_QUALIFIERS } from '../../utils/notifications/filters/search'; +import { + SEARCH_DELIMITER, + SEARCH_QUALIFIERS, +} from '../../utils/notifications/filters/search'; const QUALIFIERS = Object.values(SEARCH_QUALIFIERS); @@ -23,24 +26,42 @@ export const SearchFilterSuggestions: FC = ({ return null; } + const lower = inputValue.toLowerCase(); + const suggestions = QUALIFIERS.filter( + (q) => q.prefix.startsWith(lower) || inputValue === '', + ); + const beginsWithKnownQualifier = QUALIFIERS.some((q) => + lower.startsWith(q.prefix), + ); + return ( - {QUALIFIERS.filter( - (q) => - q.prefix.startsWith(inputValue.toLowerCase()) || - inputValue === '', - ).map((q) => ( - - - {q.prefix} + {suggestions.length > 0 && + suggestions.map((q) => ( + + + {q.prefix} + + {q.description} + + + + ))} + {inputValue !== '' && + suggestions.length === 0 && + !beginsWithKnownQualifier && ( + - {q.description} + Please use one of the supported filters [ + {QUALIFIERS.map((q) => + q.prefix.replace(SEARCH_DELIMITER, ''), + ).join(', ')} + ] - - - ))} + + )} diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index 17bf11622..f5ea6d6df 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -3,7 +3,10 @@ import { type FC, useState } from 'react'; import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; import type { SearchToken } from '../../types'; -import { normalizeSearchInputToToken } from '../../utils/notifications/filters/search'; +import { + normalizeSearchInputToToken, + SEARCH_DELIMITER, +} from '../../utils/notifications/filters/search'; import { SearchFilterSuggestions } from './SearchFilterSuggestions'; export interface TokenInputItem { @@ -81,7 +84,10 @@ export const TokenSearchInput: FC = ({ onChange={(e: React.ChangeEvent) => { setInputValue(e.target.value); const val = e.target.value.trim(); - if (!val.includes(':') || val.endsWith(':')) { + if ( + !val.includes(SEARCH_DELIMITER) || + val.endsWith(SEARCH_DELIMITER) + ) { setShowSuggestions(true); } else { setShowSuggestions(false); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index afad23ab1..f5af031e5 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -1,6 +1,8 @@ import type { SettingsState } from '../../../types'; import type { Notification } from '../../../typesGitHub'; +export const SEARCH_DELIMITER = ':'; + export const SEARCH_QUALIFIERS = { author: { prefix: 'author:', From c6f3bdc2a3ba816d03171b5b6d1849fd6867ed13 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 11:47:26 -0400 Subject: [PATCH 12/26] feat: search notifications Signed-off-by: Adam Setch --- src/renderer/components/filters/SearchFilter.tsx | 3 +++ .../components/filters/SearchFilterSuggestions.tsx | 11 ++++++++--- src/renderer/components/filters/TokenSearchInput.tsx | 7 +++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 65cabab65..67db83622 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -133,7 +133,9 @@ export const SearchFilter: FC = () => { onRemove={removeIncludeSearchToken} showSuggestionsOnFocusIfEmpty={!hasIncludeSearchFilters(settings)} tokens={includeSearchTokens} + isDetailedNotificationsEnabled={settings.detailedNotifications} /> + { onRemove={removeExcludeSearchToken} showSuggestionsOnFocusIfEmpty={!hasExcludeSearchFilters(settings)} tokens={excludeSearchTokens} + isDetailedNotificationsEnabled={settings.detailedNotifications} /> diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 20b0bfcec..6d9f77dcf 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -14,12 +14,14 @@ const QUALIFIERS = Object.values(SEARCH_QUALIFIERS); interface SearchFilterSuggestionsProps { open: boolean; inputValue: string; + isDetailedNotificationsEnabled: boolean; onClose: () => void; } export const SearchFilterSuggestions: FC = ({ open, inputValue, + isDetailedNotificationsEnabled, onClose, }) => { if (!open) { @@ -27,10 +29,13 @@ export const SearchFilterSuggestions: FC = ({ } const lower = inputValue.toLowerCase(); - const suggestions = QUALIFIERS.filter( + const base = isDetailedNotificationsEnabled + ? QUALIFIERS + : QUALIFIERS.filter((q) => !q.requiresDetailsNotifications); + const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); - const beginsWithKnownQualifier = QUALIFIERS.some((q) => + const beginsWithKnownQualifier = base.some((q) => lower.startsWith(q.prefix), ); @@ -55,7 +60,7 @@ export const SearchFilterSuggestions: FC = ({ Please use one of the supported filters [ - {QUALIFIERS.map((q) => + {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 index f5ea6d6df..71b3c9d4e 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -15,11 +15,12 @@ export interface TokenInputItem { } interface TokenSearchInputProps { - label: string; // "Include" | "Exclude" + label: string; icon: FC<{ className?: string }>; iconColorClass: string; tokens: TokenInputItem[]; - showSuggestionsOnFocusIfEmpty: boolean; // parent pre-computed condition + showSuggestionsOnFocusIfEmpty: boolean; + isDetailedNotificationsEnabled: boolean; onAdd: (token: string) => void; onRemove: (tokenId: string | number) => void; } @@ -32,6 +33,7 @@ export const TokenSearchInput: FC = ({ iconColorClass, tokens, showSuggestionsOnFocusIfEmpty, + isDetailedNotificationsEnabled, onAdd, onRemove, }) => { @@ -110,6 +112,7 @@ export const TokenSearchInput: FC = ({ setShowSuggestions(false)} + isDetailedNotificationsEnabled={isDetailedNotificationsEnabled} open={showSuggestions} /> From fbf94ca23ff11a9cb9c12bf01042fb3a2d7761c4 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 11:58:14 -0400 Subject: [PATCH 13/26] feat: search notifications Signed-off-by: Adam Setch --- .../components/filters/SearchFilterSuggestions.tsx | 8 ++------ src/renderer/utils/notifications/filters/search.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 6d9f77dcf..dc48da523 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -6,11 +6,9 @@ import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; import { SEARCH_DELIMITER, - SEARCH_QUALIFIERS, + getAvailableSearchQualifiers, } from '../../utils/notifications/filters/search'; -const QUALIFIERS = Object.values(SEARCH_QUALIFIERS); - interface SearchFilterSuggestionsProps { open: boolean; inputValue: string; @@ -29,9 +27,7 @@ export const SearchFilterSuggestions: FC = ({ } const lower = inputValue.toLowerCase(); - const base = isDetailedNotificationsEnabled - ? QUALIFIERS - : QUALIFIERS.filter((q) => !q.requiresDetailsNotifications); + const base = getAvailableSearchQualifiers(isDetailedNotificationsEnabled); const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index f5af031e5..4ef1a13e1 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -47,6 +47,18 @@ export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix; +// Qualifier selection helpers (centralized to avoid duplicating logic in UI components) +export function getAvailableSearchQualifiers( + detailedNotificationsEnabled: boolean, +): readonly SearchQualifier[] { + const all = Object.values(SEARCH_QUALIFIERS) as readonly SearchQualifier[]; + if (detailedNotificationsEnabled) return all; + return all.filter((q) => !q.requiresDetailsNotifications); +} + +export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = getAvailableSearchQualifiers(false); +export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = getAvailableSearchQualifiers(true); + export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; } From f6f60d286651ad1b1bfb2b02445fed9e86611f48 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 12:01:54 -0400 Subject: [PATCH 14/26] feat: search notifications Signed-off-by: Adam Setch --- src/renderer/components/filters/SearchFilter.tsx | 4 ++-- .../components/filters/SearchFilterSuggestions.tsx | 12 +++++------- src/renderer/components/filters/TokenSearchInput.tsx | 4 ++-- src/renderer/utils/notifications/filters/search.ts | 6 ++++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 67db83622..36e74a6f3 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -128,23 +128,23 @@ export const SearchFilter: FC = () => { diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index dc48da523..5d606b5a0 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -5,8 +5,8 @@ import { Box, Popover, Stack, Text } from '@primer/react'; import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; import { - SEARCH_DELIMITER, getAvailableSearchQualifiers, + SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; interface SearchFilterSuggestionsProps { @@ -31,9 +31,7 @@ export const SearchFilterSuggestions: FC = ({ const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); - const beginsWithKnownQualifier = base.some((q) => - lower.startsWith(q.prefix), - ); + const beginsWithKnownQualifier = base.some((q) => lower.startsWith(q.prefix)); return ( @@ -56,9 +54,9 @@ export const SearchFilterSuggestions: FC = ({ Please use one of the supported filters [ - {base.map((q) => - q.prefix.replace(SEARCH_DELIMITER, ''), - ).join(', ')} + {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 index 71b3c9d4e..db9152728 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -19,7 +19,7 @@ interface TokenSearchInputProps { icon: FC<{ className?: string }>; iconColorClass: string; tokens: TokenInputItem[]; - showSuggestionsOnFocusIfEmpty: boolean; + showSuggestionsOnFocusIfEmpty: boolean; isDetailedNotificationsEnabled: boolean; onAdd: (token: string) => void; onRemove: (tokenId: string | number) => void; @@ -111,8 +111,8 @@ export const TokenSearchInput: FC = ({ /> setShowSuggestions(false)} isDetailedNotificationsEnabled={isDetailedNotificationsEnabled} + onClose={() => setShowSuggestions(false)} open={showSuggestions} /> diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 4ef1a13e1..94f87e3bc 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -56,8 +56,10 @@ export function getAvailableSearchQualifiers( return all.filter((q) => !q.requiresDetailsNotifications); } -export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = getAvailableSearchQualifiers(false); -export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = getAvailableSearchQualifiers(true); +export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = + getAvailableSearchQualifiers(false); +export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = + getAvailableSearchQualifiers(true); export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; From 19f81b2031ae86e4d3733e53e8623f21878fcffb Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 8 Sep 2025 12:28:29 -0400 Subject: [PATCH 15/26] feat: search notifications Signed-off-by: Adam Setch --- biome.json | 1 + src/main/first-run.ts | 8 ++- src/main/updater.test.ts | 12 ++-- src/main/updater.ts | 4 +- .../components/filters/SearchFilter.tsx | 68 ++++++++----------- .../components/filters/TokenSearchInput.tsx | 28 +++++--- .../notifications/AccountNotifications.tsx | 5 +- src/renderer/utils/zoom.ts | 10 ++- 8 files changed, 76 insertions(+), 60 deletions(-) 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/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/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/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 36e74a6f3..a5c7cc27a 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -1,4 +1,4 @@ -import { type FC, useContext, useEffect, useId, useState } from 'react'; +import { type FC, useContext, useEffect, useState } from 'react'; import { CheckCircleFillIcon, @@ -21,8 +21,6 @@ import { Title } from '../primitives/Title'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; import { TokenSearchInput } from './TokenSearchInput'; -type InputToken = { id: number; text: string }; - export const SearchFilter: FC = () => { const { updateFilter, settings } = useContext(AppContext); @@ -37,58 +35,52 @@ export const SearchFilter: FC = () => { } }, [settings.filterIncludeSearchTokens, settings.filterExcludeSearchTokens]); - const mapValuesToTokens = (values: string[]): InputToken[] => - values.map((value, index) => ({ id: index, text: value })); - - const [includeSearchTokens, setIncludeSearchTokens] = useState( - mapValuesToTokens(settings.filterIncludeSearchTokens), + const [includeSearchTokens, setIncludeSearchTokens] = useState( + settings.filterIncludeSearchTokens, ); const addIncludeSearchToken = (value: string) => { - if (!value || includeSearchTokens.some((v) => v.text === value)) return; - const nextId = - includeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; - setIncludeSearchTokens([ - ...includeSearchTokens, - { id: nextId, text: value }, - ]); + if (!value || includeSearchTokens.includes(value as SearchToken)) { + return; + } + + setIncludeSearchTokens([...includeSearchTokens, value as SearchToken]); updateFilter('filterIncludeSearchTokens', value as SearchToken, true); }; - const removeIncludeSearchToken = (tokenId: string | number) => { - const value = includeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - if (value) - updateFilter('filterIncludeSearchTokens', value as SearchToken, false); - setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId)); + const removeIncludeSearchToken = (token: SearchToken) => { + if (!token) { + return; + } + + updateFilter('filterIncludeSearchTokens', token, false); + setIncludeSearchTokens(includeSearchTokens.filter((t) => t !== token)); }; - const [excludeSearchTokens, setExcludeSearchTokens] = useState( - mapValuesToTokens(settings.filterExcludeSearchTokens), + const [excludeSearchTokens, setExcludeSearchTokens] = useState( + settings.filterExcludeSearchTokens as SearchToken[], ); const addExcludeSearchToken = (value: string) => { - if (!value || excludeSearchTokens.some((v) => v.text === value)) return; - const nextId = - excludeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1; - setExcludeSearchTokens([ - ...excludeSearchTokens, - { id: nextId, text: value }, - ]); + if (!value || excludeSearchTokens.includes(value as SearchToken)) { + return; + } + + setExcludeSearchTokens([...excludeSearchTokens, value as SearchToken]); updateFilter('filterExcludeSearchTokens', value as SearchToken, true); }; - const removeExcludeSearchToken = (tokenId: string | number) => { - const value = excludeSearchTokens.find((v) => v.id === tokenId)?.text || ''; - if (value) - updateFilter('filterExcludeSearchTokens', value as SearchToken, false); - setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId)); - }; + const removeExcludeSearchToken = (token: SearchToken) => { + if (!token) { + return; + } - // Basic suggestions for prefixes - const fieldsetId = useId(); + updateFilter('filterExcludeSearchTokens', token, false); + setExcludeSearchTokens(excludeSearchTokens.filter((t) => t !== token)); + }; return ( -
+
; iconColorClass: string; - tokens: TokenInputItem[]; + tokens: readonly SearchToken[]; // raw token strings showSuggestionsOnFocusIfEmpty: boolean; isDetailedNotificationsEnabled: boolean; - onAdd: (token: string) => void; - onRemove: (tokenId: string | number) => void; + onAdd: (token: SearchToken) => void; + onRemove: (token: SearchToken) => void; } const tokenEvents = ['Enter', 'Tab', ' ', ',']; @@ -40,6 +35,9 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ const [inputValue, setInputValue] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); + // FIXME - remove this + const tokenItems = tokens.map((text, id) => ({ id, text })); + function tryAddToken( event: | React.KeyboardEvent<HTMLInputElement> @@ -47,7 +45,7 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ ) { const raw = (event.target as HTMLInputElement).value; const value = normalizeSearchInputToToken(raw); - if (value && !tokens.some((t) => t.text === value)) { + if (value && !tokens.includes(value as SearchToken)) { onAdd(value as SearchToken); (event.target as HTMLInputElement).value = ''; setInputValue(''); @@ -104,10 +102,18 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ } }} onKeyDown={onKeyDown} - onTokenRemove={onRemove} + onTokenRemove={(id) => { + const token = tokenItems.find((t) => t.id === id)?.text as + | SearchToken + | undefined; + + if (token) { + onRemove(token); + } + }} size="small" title={`${label} searches`} - tokens={tokens} + tokens={tokenItems} /> <SearchFilterSuggestions inputValue={inputValue} 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<IAccountNotifications> = ( 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/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; }; From ee005272c44effa1caeaa0e1573a6c5d2a8101be Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 12:37:25 -0400 Subject: [PATCH 16/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../components/filters/TokenSearchInput.tsx | 1 - .../utils/notifications/filters/search.ts | 38 +++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index bf75dcc22..ad5213ea0 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -35,7 +35,6 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ const [inputValue, setInputValue] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); - // FIXME - remove this const tokenItems = tokens.map((text, id) => ({ id, text })); function tryAddToken( diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 94f87e3bc..2a5dad5fa 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -52,7 +52,10 @@ export function getAvailableSearchQualifiers( detailedNotificationsEnabled: boolean, ): readonly SearchQualifier[] { const all = Object.values(SEARCH_QUALIFIERS) as readonly SearchQualifier[]; - if (detailedNotificationsEnabled) return all; + if (detailedNotificationsEnabled) { + return all; + } + return all.filter((q) => !q.requiresDetailsNotifications); } @@ -91,9 +94,15 @@ export interface ParsedSearchToken { export function parseSearchToken(token: string): ParsedSearchToken | null { const qualifier = matchQualifierByPrefix(token); - if (!qualifier) return null; + if (!qualifier) { + return null; + } + const value = stripPrefix(token, qualifier); - if (!value) return null; + if (!value) { + return null; + } + return { qualifier, value, valueLower: value.toLowerCase() }; } @@ -101,13 +110,23 @@ export function parseSearchToken(token: string): ParsedSearchToken | null { // Returns null if no known prefix or no value after prefix yet. export function normalizeSearchInputToToken(raw: string): string | null { const value = raw.trim(); - if (!value) return null; + if (!value) { + return null; + } + const lower = value.toLowerCase(); const matched = SEARCH_PREFIXES.find((p) => lower.startsWith(p)); - if (!matched) return null; + + if (!matched) { + return null; + } + const rest = value.substring(matched.length); - if (rest.length === 0) return null; // prefix only, incomplete token - return `${matched}${rest}`; // preserve original rest casing + if (rest.length === 0) { + return null; // prefix only, incomplete token + } + + return `${matched}${rest}`; } export function filterNotificationBySearchTerm( @@ -115,7 +134,10 @@ export function filterNotificationBySearchTerm( token: string, ): boolean { const parsed = parseSearchToken(token); - if (!parsed) return false; + if (!parsed) { + return false; + } + const fieldValue = parsed.qualifier.extract(notification); return fieldValue?.toLowerCase() === parsed.valueLower; } From 4f31c3eb270e958976246edafd274af322028392 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 12:48:20 -0400 Subject: [PATCH 17/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../__snapshots__/Filters.test.tsx.snap | 8 ++--- .../notifications/filters/search.test.ts | 14 +++----- .../utils/notifications/filters/search.ts | 35 +++++++------------ 3 files changed, 19 insertions(+), 38 deletions(-) diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index 58ad4aaf3..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" > - <fieldset - id="«r1»" - > + <fieldset> <legend> <div class="Box-sc-g0xbh4-0 mb-2" @@ -1976,11 +1974,11 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children <span aria-label="Clear all filters" class="Tooltip__TooltipBase-sc-17tf59c-0 ihgdlF tooltipped-n" - id="«r2»" + id="«r1»" role="tooltip" > <button - aria-describedby="«r3»-loading-announcement" + aria-describedby="«r2»-loading-announcement" class="types__StyledButton-sc-ws60qy-0 fnSTVi" data-loading="false" data-testid="filters-clear" diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index 729dacc5e..9f481f7c2 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -6,9 +6,8 @@ import { filterNotificationBySearchTerm, matchQualifierByPrefix, ORG_PREFIX, + QUALIFIERS, REPO_PREFIX, - SEARCH_PREFIXES, - SEARCH_QUALIFIERS, } from './search'; // (helper removed – no longer used) @@ -25,16 +24,11 @@ describe('renderer/utils/notifications/filters/search.ts', () => { }); it('matches each known qualifier by its exact prefix and additional value', () => { - for (const prefix of SEARCH_PREFIXES) { - const token = prefix + 'someValue'; + for (const q of QUALIFIERS) { + const token = q.prefix + 'someValue'; const qualifier = matchQualifierByPrefix(token); expect(qualifier).not.toBeNull(); - if (qualifier) { - const found = Object.values(SEARCH_QUALIFIERS).find( - (q) => q.prefix === prefix, - ); - expect(qualifier).toBe(found); - } + expect(qualifier).toBe(q); } }); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 2a5dad5fa..4e783c83c 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -3,7 +3,7 @@ import type { Notification } from '../../../typesGitHub'; export const SEARCH_DELIMITER = ':'; -export const SEARCH_QUALIFIERS = { +const SEARCH_QUALIFIERS = { author: { prefix: 'author:', description: 'filter by notification author', @@ -25,23 +25,13 @@ export const SEARCH_QUALIFIERS = { }, } as const; -export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; -export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; +type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; +type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; export type SearchPrefix = SearchQualifier['prefix']; -export const SEARCH_PREFIXES: readonly SearchPrefix[] = Object.values( +export const QUALIFIERS: readonly SearchQualifier[] = Object.values( SEARCH_QUALIFIERS, -).map((q) => q.prefix) as readonly SearchPrefix[]; - -// Map prefix -> qualifier for fast lookup after prefix detection -export const QUALIFIER_BY_PREFIX: Record<SearchPrefix, SearchQualifier> = - Object.values(SEARCH_QUALIFIERS).reduce( - (acc, q) => { - acc[q.prefix as SearchPrefix] = q; - return acc; - }, - {} as Record<SearchPrefix, SearchQualifier>, - ); +) as readonly SearchQualifier[]; export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; @@ -73,10 +63,9 @@ export function hasExcludeSearchFilters(settings: SettingsState) { } export function matchQualifierByPrefix(token: string) { - // Iterate prefixes (tiny list) then direct map lookup; preserves ordering behavior - for (const prefix of SEARCH_PREFIXES) { - if (token.startsWith(prefix)) { - return QUALIFIER_BY_PREFIX[prefix] || null; + for (const qualifier of QUALIFIERS) { + if (token.startsWith(qualifier.prefix)) { + return qualifier; } } return null; @@ -115,18 +104,18 @@ export function normalizeSearchInputToToken(raw: string): string | null { } const lower = value.toLowerCase(); - const matched = SEARCH_PREFIXES.find((p) => lower.startsWith(p)); + const matchedQualifier = QUALIFIERS.find((q) => lower.startsWith(q.prefix)); - if (!matched) { + if (!matchedQualifier) { return null; } - const rest = value.substring(matched.length); + const rest = value.substring(matchedQualifier.prefix.length); if (rest.length === 0) { return null; // prefix only, incomplete token } - return `${matched}${rest}`; + return `${matchedQualifier.prefix}${rest}`; } export function filterNotificationBySearchTerm( From 389f2c5926e0f4ce4e1503442fec4259b8ec7703 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 12:54:44 -0400 Subject: [PATCH 18/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../filters/SearchFilterSuggestions.tsx | 5 ++- .../utils/notifications/filters/filter.ts | 39 +++++++++++-------- .../utils/notifications/filters/search.ts | 14 ++++++- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 5d606b5a0..5e0121d2f 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -5,7 +5,8 @@ import { Box, Popover, Stack, Text } from '@primer/react'; import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; import { - getAvailableSearchQualifiers, + BASE_QUALIFIERS, + QUALIFIERS, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; @@ -27,7 +28,7 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({ } const lower = inputValue.toLowerCase(); - const base = getAvailableSearchQualifiers(isDetailedNotificationsEnabled); + const base = isDetailedNotificationsEnabled ? QUALIFIERS : BASE_QUALIFIERS; const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index d7006e7b4..a22670be7 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,19 +5,20 @@ import type { SubjectUser, } from '../../../typesGitHub'; import { - AUTHOR_PREFIX, filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, - ORG_PREFIX, - REPO_PREFIX, reasonFilter, - type SearchPrefix, stateFilter, subjectTypeFilter, userTypeFilter, + type SearchQualifier, + BASE_QUALIFIERS, + DETAILED_ONLY_QUALIFIERS, } from '.'; + + export function filterBaseNotifications( notifications: Notification[], settings: SettingsState, @@ -25,13 +26,13 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; - passesFilters = - passesFilters && - passesSearchTokenFiltersForPrefix(notification, settings, ORG_PREFIX); - - passesFilters = - passesFilters && - passesSearchTokenFiltersForPrefix(notification, settings, REPO_PREFIX); + // Apply base qualifier include/exclude filters (org, repo, etc.) + for (const qualifier of BASE_QUALIFIERS) { + if (!passesFilters) break; + passesFilters = + passesFilters && + passesSearchTokenFiltersForQualifier(notification, settings, qualifier); + } if (subjectTypeFilter.hasFilters(settings)) { passesFilters = @@ -86,12 +87,13 @@ export function hasAnyFiltersSet(settings: SettingsState): boolean { /** * Apply include/exclude search token logic for a specific search qualifier prefix. */ -function passesSearchTokenFiltersForPrefix( +function passesSearchTokenFiltersForQualifier( notification: Notification, settings: SettingsState, - prefix: SearchPrefix, + qualifier: SearchQualifier, ): boolean { let passes = true; + const prefix = qualifier.prefix; if (hasIncludeSearchFilters(settings)) { const includeTokens = settings.filterIncludeSearchTokens.filter((t) => @@ -136,10 +138,13 @@ function passesUserFilters( ); } - // Apply author-specific search token filters during detailed filtering - passesFilters = - passesFilters && - passesSearchTokenFiltersForPrefix(notification, settings, AUTHOR_PREFIX); + // Apply detailed-only qualifier search token filters (e.g. author) + for (const qualifier of DETAILED_ONLY_QUALIFIERS) { + if (!passesFilters) break; + passesFilters = + passesFilters && + passesSearchTokenFiltersForQualifier(notification, settings, qualifier); + } return passesFilters; } diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 4e783c83c..7b72c77a7 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -25,14 +25,24 @@ const SEARCH_QUALIFIERS = { }, } as const; -type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; -type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; +export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; +export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; export type SearchPrefix = SearchQualifier['prefix']; export const QUALIFIERS: readonly SearchQualifier[] = Object.values( SEARCH_QUALIFIERS, ) as readonly SearchQualifier[]; + +// Pre-split qualifier sets so we don't filter each time for every notification +export const BASE_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.filter( + (q) => !q.requiresDetailsNotifications, +); + +export const DETAILED_ONLY_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.filter( + (q) => q.requiresDetailsNotifications, +); + export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix; From af62e4c4df9a08bf581a646511b39323a8f255e0 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:12:31 -0400 Subject: [PATCH 19/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- src/renderer/utils/notifications/filters/search.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 7b72c77a7..42ceb5721 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -34,7 +34,6 @@ export const QUALIFIERS: readonly SearchQualifier[] = Object.values( ) as readonly SearchQualifier[]; -// Pre-split qualifier sets so we don't filter each time for every notification export const BASE_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.filter( (q) => !q.requiresDetailsNotifications, ); From 236a624d33f01ddfe56050624c8fa018af3bd866 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:15:53 -0400 Subject: [PATCH 20/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../notifications/filters/filter.test.ts | 33 ++++++------------- .../notifications/filters/search.test.ts | 24 +++++--------- .../utils/notifications/filters/search.ts | 3 -- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index d04ff242a..5ade78f89 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -8,7 +8,6 @@ import { filterDetailedNotifications, hasAnyFiltersSet, } from './filter'; -import { AUTHOR_PREFIX, ORG_PREFIX, REPO_PREFIX } from './search'; describe('renderer/utils/notifications/filters/filter.ts', () => { afterEach(() => { @@ -83,7 +82,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match include organization', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeSearchTokens: [`${ORG_PREFIX}gitify-app` as SearchToken], + filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], }); expect(result.length).toBe(1); @@ -93,7 +92,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match exclude organization', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterExcludeSearchTokens: [`${ORG_PREFIX}github` as SearchToken], + filterExcludeSearchTokens: ['org:github' as SearchToken], }); expect(result.length).toBe(1); @@ -103,9 +102,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match include repository', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterIncludeSearchTokens: [ - `${REPO_PREFIX}gitify-app/gitify` as SearchToken, - ], + filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], }); expect(result.length).toBe(1); @@ -115,9 +112,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match exclude repository', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, - filterExcludeSearchTokens: [ - `${REPO_PREFIX}github/github` as SearchToken, - ], + filterExcludeSearchTokens: ['repo:github/github' as SearchToken], }); expect(result.length).toBe(1); @@ -131,12 +126,8 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { ...mockSettings, detailedNotifications: false, filterUserTypes: ['Bot'], - filterIncludeSearchTokens: [ - `${AUTHOR_PREFIX}github-user` as SearchToken, - ], - filterExcludeSearchTokens: [ - `${AUTHOR_PREFIX}github-bot` as SearchToken, - ], + filterIncludeSearchTokens: ['author:github-user' as SearchToken], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], filterStates: ['merged'], }); @@ -159,9 +150,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: [ - `${AUTHOR_PREFIX}github-user` as SearchToken, - ], + filterIncludeSearchTokens: ['author:github-user' as SearchToken], }); expect(result.length).toBe(1); @@ -172,9 +161,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterExcludeSearchTokens: [ - `${AUTHOR_PREFIX}github-bot` as SearchToken, - ], + filterExcludeSearchTokens: ['author:github-bot' as SearchToken], }); expect(result.length).toBe(1); @@ -203,7 +190,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default search token includes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterIncludeSearchTokens: [`${AUTHOR_PREFIX}gitify` as SearchToken], + filterIncludeSearchTokens: ['author:gitify' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); @@ -211,7 +198,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('non-default search token excludes filters', () => { const settings: SettingsState = { ...defaultSettings, - filterExcludeSearchTokens: [`${ORG_PREFIX}github` as SearchToken], + filterExcludeSearchTokens: ['org:github' as SearchToken], }; expect(hasAnyFiltersSet(settings)).toBe(true); }); diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index 9f481f7c2..d246e46c9 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -2,12 +2,9 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import type { Link } from '../../../types'; import type { Owner } from '../../../typesGitHub'; import { - AUTHOR_PREFIX, filterNotificationBySearchTerm, matchQualifierByPrefix, - ORG_PREFIX, QUALIFIERS, - REPO_PREFIX, } from './search'; // (helper removed – no longer used) @@ -67,23 +64,20 @@ describe('renderer/utils/notifications/filters/search.ts', () => { it('matches author qualifier (case-insensitive)', () => { expect( - filterNotificationBySearchTerm( - mockNotification, - `${AUTHOR_PREFIX}github-user`, - ), + filterNotificationBySearchTerm(mockNotification, `author:github-user`), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `${AUTHOR_PREFIX}GITHUB-USER`, + `author:GITHUB-USER`, ), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `${AUTHOR_PREFIX}some-bot`, + `author:some-bot`, ), ).toBe(false); }); @@ -92,19 +86,19 @@ describe('renderer/utils/notifications/filters/search.ts', () => { expect( filterNotificationBySearchTerm( mockNotification, - `${ORG_PREFIX}gitify-app`, + `org:gitify-app`, ), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `${ORG_PREFIX}GITIFY-APP`, + `org:GITIFY-APP`, ), ).toBe(true); expect( - filterNotificationBySearchTerm(mockNotification, `${ORG_PREFIX}github`), + filterNotificationBySearchTerm(mockNotification, `org:github`), ).toBe(false); }); @@ -112,21 +106,21 @@ describe('renderer/utils/notifications/filters/search.ts', () => { expect( filterNotificationBySearchTerm( mockNotification, - `${REPO_PREFIX}gitify-app/gitify`, + `repo:gitify-app/gitify`, ), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `${REPO_PREFIX}Gitify-App/Gitify`, + `repo:Gitify-App/Gitify`, ), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `${REPO_PREFIX}github/other`, + `repo:github/other`, ), ).toBe(false); }); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 42ceb5721..6174cdb5f 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -42,9 +42,6 @@ export const DETAILED_ONLY_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.f (q) => q.requiresDetailsNotifications, ); -export const AUTHOR_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.author.prefix; -export const ORG_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.org.prefix; -export const REPO_PREFIX: SearchPrefix = SEARCH_QUALIFIERS.repo.prefix; // Qualifier selection helpers (centralized to avoid duplicating logic in UI components) export function getAvailableSearchQualifiers( From c0d2ecc62cc9a1722f2994e1927e1b2821e84cc6 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:22:13 -0400 Subject: [PATCH 21/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../filters/SearchFilterSuggestions.tsx | 6 +-- .../utils/notifications/filters/filter.ts | 8 +-- .../notifications/filters/search.test.ts | 32 +++++------ .../utils/notifications/filters/search.ts | 53 ++++++------------- 4 files changed, 37 insertions(+), 62 deletions(-) diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 5e0121d2f..a3caf92aa 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -5,8 +5,8 @@ import { Box, Popover, Stack, Text } from '@primer/react'; import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; import { - BASE_QUALIFIERS, - QUALIFIERS, + BASE_SEARCH_QUALIFIERS, + ALL_SEARCH_QUALIFIERS, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; @@ -28,7 +28,7 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({ } const lower = inputValue.toLowerCase(); - const base = isDetailedNotificationsEnabled ? QUALIFIERS : BASE_QUALIFIERS; + const base = isDetailedNotificationsEnabled ? ALL_SEARCH_QUALIFIERS : BASE_SEARCH_QUALIFIERS; const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index a22670be7..d9d25cf4c 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -13,8 +13,8 @@ import { subjectTypeFilter, userTypeFilter, type SearchQualifier, - BASE_QUALIFIERS, - DETAILED_ONLY_QUALIFIERS, + BASE_SEARCH_QUALIFIERS, + DETAILED_ONLY_SEARCH_QUALIFIERS, } from '.'; @@ -27,7 +27,7 @@ export function filterBaseNotifications( let passesFilters = true; // Apply base qualifier include/exclude filters (org, repo, etc.) - for (const qualifier of BASE_QUALIFIERS) { + for (const qualifier of BASE_SEARCH_QUALIFIERS) { if (!passesFilters) break; passesFilters = passesFilters && @@ -139,7 +139,7 @@ function passesUserFilters( } // Apply detailed-only qualifier search token filters (e.g. author) - for (const qualifier of DETAILED_ONLY_QUALIFIERS) { + for (const qualifier of DETAILED_ONLY_SEARCH_QUALIFIERS) { if (!passesFilters) break; passesFilters = passesFilters && diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index d246e46c9..215b4f27c 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -1,44 +1,40 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import type { Link } from '../../../types'; import type { Owner } from '../../../typesGitHub'; -import { - filterNotificationBySearchTerm, - matchQualifierByPrefix, - QUALIFIERS, -} from './search'; +import { filterNotificationBySearchTerm, parseSearchToken, ALL_SEARCH_QUALIFIERS } from './search'; // (helper removed – no longer used) describe('renderer/utils/notifications/filters/search.ts', () => { - describe('matchQualifierByPrefix', () => { + describe('parseSearchToken (prefix matching behavior)', () => { it('returns null for empty string', () => { - expect(matchQualifierByPrefix('')).toBeNull(); + expect(parseSearchToken('')).toBeNull(); }); it('returns null when no qualifier prefix matches', () => { - expect(matchQualifierByPrefix('unknown:value')).toBeNull(); - expect(matchQualifierByPrefix('auth:foo')).toBeNull(); // near miss + expect(parseSearchToken('unknown:value')).toBeNull(); + expect(parseSearchToken('auth:foo')).toBeNull(); // near miss }); it('matches each known qualifier by its exact prefix and additional value', () => { - for (const q of QUALIFIERS) { + for (const q of ALL_SEARCH_QUALIFIERS) { const token = q.prefix + 'someValue'; - const qualifier = matchQualifierByPrefix(token); - expect(qualifier).not.toBeNull(); - expect(qualifier).toBe(q); + const parsed = parseSearchToken(token); + expect(parsed).not.toBeNull(); + expect(parsed?.qualifier).toBe(q); } }); it('is case-sensitive (does not match mismatched casing)', () => { // Intentionally alter case of prefix characters - expect(matchQualifierByPrefix('Author:foo')).toBeNull(); - expect(matchQualifierByPrefix('ORG:bar')).toBeNull(); - expect(matchQualifierByPrefix('Repo:baz')).toBeNull(); + expect(parseSearchToken('Author:foo')).toBeNull(); + expect(parseSearchToken('ORG:bar')).toBeNull(); + expect(parseSearchToken('Repo:baz')).toBeNull(); }); it('does not match when prefix appears later in the token', () => { - expect(matchQualifierByPrefix('xauthor:foo')).toBeNull(); - expect(matchQualifierByPrefix('xxorg:bar')).toBeNull(); + expect(parseSearchToken('xauthor:foo')).toBeNull(); + expect(parseSearchToken('xxorg:bar')).toBeNull(); }); }); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 6174cdb5f..58389d617 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -29,37 +29,20 @@ export type SearchQualifierKey = keyof typeof SEARCH_QUALIFIERS; export type SearchQualifier = (typeof SEARCH_QUALIFIERS)[SearchQualifierKey]; export type SearchPrefix = SearchQualifier['prefix']; -export const QUALIFIERS: readonly SearchQualifier[] = Object.values( +export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = Object.values( SEARCH_QUALIFIERS, ) as readonly SearchQualifier[]; -export const BASE_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.filter( +export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = ALL_SEARCH_QUALIFIERS.filter( (q) => !q.requiresDetailsNotifications, ); -export const DETAILED_ONLY_QUALIFIERS: readonly SearchQualifier[] = QUALIFIERS.filter( +export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] = ALL_SEARCH_QUALIFIERS.filter( (q) => q.requiresDetailsNotifications, ); -// Qualifier selection helpers (centralized to avoid duplicating logic in UI components) -export function getAvailableSearchQualifiers( - detailedNotificationsEnabled: boolean, -): readonly SearchQualifier[] { - const all = Object.values(SEARCH_QUALIFIERS) as readonly SearchQualifier[]; - if (detailedNotificationsEnabled) { - return all; - } - - return all.filter((q) => !q.requiresDetailsNotifications); -} - -export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = - getAvailableSearchQualifiers(false); -export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = - getAvailableSearchQualifiers(true); - export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; } @@ -68,15 +51,6 @@ export function hasExcludeSearchFilters(settings: SettingsState) { return settings.filterExcludeSearchTokens.length > 0; } -export function matchQualifierByPrefix(token: string) { - for (const qualifier of QUALIFIERS) { - if (token.startsWith(qualifier.prefix)) { - return qualifier; - } - } - return null; -} - function stripPrefix(token: string, qualifier: SearchQualifier) { return token.slice(qualifier.prefix.length).trim(); } @@ -88,17 +62,22 @@ export interface ParsedSearchToken { } export function parseSearchToken(token: string): ParsedSearchToken | null { - const qualifier = matchQualifierByPrefix(token); - if (!qualifier) { + if (!token) { return null; } - const value = stripPrefix(token, qualifier); - if (!value) { - return null; + for (const qualifier of ALL_SEARCH_QUALIFIERS) { + if (token.startsWith(qualifier.prefix)) { + const value = stripPrefix(token, qualifier); + + if (!value) { + return null; // prefix only + } + + return { qualifier, value, valueLower: value.toLowerCase() }; + } } - - return { qualifier, value, valueLower: value.toLowerCase() }; + return null; } // Normalize raw user input from the token text field into a SearchToken (string) @@ -110,7 +89,7 @@ export function normalizeSearchInputToToken(raw: string): string | null { } const lower = value.toLowerCase(); - const matchedQualifier = QUALIFIERS.find((q) => lower.startsWith(q.prefix)); + const matchedQualifier = ALL_SEARCH_QUALIFIERS.find((q) => lower.startsWith(q.prefix)); if (!matchedQualifier) { return null; From 44874040f56b5f0da0746730efc7340f1bc0e00b Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:30:37 -0400 Subject: [PATCH 22/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../filters/SearchFilterSuggestions.tsx | 6 +- .../components/filters/TokenSearchInput.tsx | 13 ++-- .../utils/notifications/filters/filter.ts | 18 +++-- .../notifications/filters/search.test.ts | 60 ++++++--------- .../utils/notifications/filters/search.ts | 75 +++++++------------ 5 files changed, 70 insertions(+), 102 deletions(-) diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index a3caf92aa..9e656b79f 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -5,8 +5,8 @@ import { Box, Popover, Stack, Text } from '@primer/react'; import { Opacity } from '../../types'; import { cn } from '../../utils/cn'; import { - BASE_SEARCH_QUALIFIERS, ALL_SEARCH_QUALIFIERS, + BASE_SEARCH_QUALIFIERS, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; @@ -28,7 +28,9 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({ } const lower = inputValue.toLowerCase(); - const base = isDetailedNotificationsEnabled ? ALL_SEARCH_QUALIFIERS : BASE_SEARCH_QUALIFIERS; + const base = isDetailedNotificationsEnabled + ? ALL_SEARCH_QUALIFIERS + : BASE_SEARCH_QUALIFIERS; const suggestions = base.filter( (q) => q.prefix.startsWith(lower) || inputValue === '', ); diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index ad5213ea0..ee701f0a9 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -4,7 +4,7 @@ import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; import type { SearchToken } from '../../types'; import { - normalizeSearchInputToToken, + parseSearchInput, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; import { SearchFilterSuggestions } from './SearchFilterSuggestions'; @@ -20,7 +20,7 @@ interface TokenSearchInputProps { onRemove: (token: SearchToken) => void; } -const tokenEvents = ['Enter', 'Tab', ' ', ',']; +const INPUT_KEY_EVENTS = ['Enter', 'Tab', ' ', ',']; export const TokenSearchInput: FC<TokenSearchInputProps> = ({ label, @@ -43,16 +43,17 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ | React.FocusEvent<HTMLInputElement>, ) { const raw = (event.target as HTMLInputElement).value; - const value = normalizeSearchInputToToken(raw); - if (value && !tokens.includes(value as SearchToken)) { - onAdd(value as SearchToken); + 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<HTMLInputElement>) { - if (tokenEvents.includes(e.key)) { + if (INPUT_KEY_EVENTS.includes(e.key)) { tryAddToken(e); setShowSuggestions(false); } else if (e.key === 'ArrowDown') { diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index d9d25cf4c..2c1c93011 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,20 +5,18 @@ import type { SubjectUser, } from '../../../typesGitHub'; import { + BASE_SEARCH_QUALIFIERS, + DETAILED_ONLY_SEARCH_QUALIFIERS, filterNotificationBySearchTerm, hasExcludeSearchFilters, hasIncludeSearchFilters, reasonFilter, + type SearchQualifier, stateFilter, subjectTypeFilter, userTypeFilter, - type SearchQualifier, - BASE_SEARCH_QUALIFIERS, - DETAILED_ONLY_SEARCH_QUALIFIERS, } from '.'; - - export function filterBaseNotifications( notifications: Notification[], settings: SettingsState, @@ -28,7 +26,10 @@ export function filterBaseNotifications( // Apply base qualifier include/exclude filters (org, repo, etc.) for (const qualifier of BASE_SEARCH_QUALIFIERS) { - if (!passesFilters) break; + if (!passesFilters) { + break; + } + passesFilters = passesFilters && passesSearchTokenFiltersForQualifier(notification, settings, qualifier); @@ -140,7 +141,10 @@ function passesUserFilters( // Apply detailed-only qualifier search token filters (e.g. author) for (const qualifier of DETAILED_ONLY_SEARCH_QUALIFIERS) { - if (!passesFilters) break; + if (!passesFilters) { + break; + } + passesFilters = passesFilters && passesSearchTokenFiltersForQualifier(notification, settings, qualifier); diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index 215b4f27c..3b590925b 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -1,40 +1,37 @@ import { partialMockNotification } from '../../../__mocks__/partial-mocks'; import type { Link } from '../../../types'; import type { Owner } from '../../../typesGitHub'; -import { filterNotificationBySearchTerm, parseSearchToken, ALL_SEARCH_QUALIFIERS } from './search'; +import { + ALL_SEARCH_QUALIFIERS, + filterNotificationBySearchTerm, + parseSearchInput, +} from './search'; // (helper removed – no longer used) describe('renderer/utils/notifications/filters/search.ts', () => { - describe('parseSearchToken (prefix matching behavior)', () => { + describe('parseSearchInput (prefix matching behavior)', () => { it('returns null for empty string', () => { - expect(parseSearchToken('')).toBeNull(); + expect(parseSearchInput('')).toBeNull(); }); it('returns null when no qualifier prefix matches', () => { - expect(parseSearchToken('unknown:value')).toBeNull(); - expect(parseSearchToken('auth:foo')).toBeNull(); // near miss + 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 = parseSearchToken(token); + const parsed = parseSearchInput(token); expect(parsed).not.toBeNull(); expect(parsed?.qualifier).toBe(q); } }); - it('is case-sensitive (does not match mismatched casing)', () => { - // Intentionally alter case of prefix characters - expect(parseSearchToken('Author:foo')).toBeNull(); - expect(parseSearchToken('ORG:bar')).toBeNull(); - expect(parseSearchToken('Repo:baz')).toBeNull(); - }); - it('does not match when prefix appears later in the token', () => { - expect(parseSearchToken('xauthor:foo')).toBeNull(); - expect(parseSearchToken('xxorg:bar')).toBeNull(); + expect(parseSearchInput('xauthor:foo')).toBeNull(); + expect(parseSearchInput('xxorg:bar')).toBeNull(); }); }); @@ -60,41 +57,29 @@ describe('renderer/utils/notifications/filters/search.ts', () => { it('matches author qualifier (case-insensitive)', () => { expect( - filterNotificationBySearchTerm(mockNotification, `author:github-user`), + filterNotificationBySearchTerm(mockNotification, 'author:github-user'), ).toBe(true); expect( - filterNotificationBySearchTerm( - mockNotification, - `author:GITHUB-USER`, - ), + filterNotificationBySearchTerm(mockNotification, 'author:GITHUB-USER'), ).toBe(true); expect( - filterNotificationBySearchTerm( - mockNotification, - `author:some-bot`, - ), + filterNotificationBySearchTerm(mockNotification, 'author:some-bot'), ).toBe(false); }); it('matches org qualifier (case-insensitive)', () => { expect( - filterNotificationBySearchTerm( - mockNotification, - `org:gitify-app`, - ), + filterNotificationBySearchTerm(mockNotification, 'org:gitify-app'), ).toBe(true); expect( - filterNotificationBySearchTerm( - mockNotification, - `org:GITIFY-APP`, - ), + filterNotificationBySearchTerm(mockNotification, 'org:GITIFY-APP'), ).toBe(true); expect( - filterNotificationBySearchTerm(mockNotification, `org:github`), + filterNotificationBySearchTerm(mockNotification, 'org:github'), ).toBe(false); }); @@ -102,22 +87,19 @@ describe('renderer/utils/notifications/filters/search.ts', () => { expect( filterNotificationBySearchTerm( mockNotification, - `repo:gitify-app/gitify`, + 'repo:gitify-app/gitify', ), ).toBe(true); expect( filterNotificationBySearchTerm( mockNotification, - `repo:Gitify-App/Gitify`, + 'repo:Gitify-App/Gitify', ), ).toBe(true); expect( - filterNotificationBySearchTerm( - mockNotification, - `repo:github/other`, - ), + filterNotificationBySearchTerm(mockNotification, 'repo:github/other'), ).toBe(false); }); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 58389d617..64cc73d2e 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -33,15 +33,11 @@ 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 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 const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] = + ALL_SEARCH_QUALIFIERS.filter((q) => q.requiresDetailsNotifications); export function hasIncludeSearchFilters(settings: SettingsState) { return settings.filterIncludeSearchTokens.length > 0; @@ -51,63 +47,46 @@ export function hasExcludeSearchFilters(settings: SettingsState) { return settings.filterExcludeSearchTokens.length > 0; } -function stripPrefix(token: string, qualifier: SearchQualifier) { - return token.slice(qualifier.prefix.length).trim(); -} - export interface ParsedSearchToken { - qualifier: SearchQualifier; - value: string; - valueLower: string; + qualifier: SearchQualifier; // matched qualifier + value: string; // original-case value after prefix + valueLower: string; // lowercase cached + token: string; // canonical stored token (prefix + value) } -export function parseSearchToken(token: string): ParsedSearchToken | null { - if (!token) { +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 (token.startsWith(qualifier.prefix)) { - const value = stripPrefix(token, qualifier); - - if (!value) { - return null; // prefix only + if (lower.startsWith(qualifier.prefix)) { + const valuePart = trimmed.slice(qualifier.prefix.length).trim(); + if (!valuePart) { + return null; } - - return { qualifier, value, valueLower: value.toLowerCase() }; + + const token = qualifier.prefix + valuePart; + return { + qualifier, + value: valuePart, + valueLower: valuePart.toLowerCase(), + token, + }; } } return null; } -// Normalize raw user input from the token text field into a SearchToken (string) -// Returns null if no known prefix or no value after prefix yet. -export function normalizeSearchInputToToken(raw: string): string | null { - const value = raw.trim(); - if (!value) { - return null; - } - - const lower = value.toLowerCase(); - const matchedQualifier = ALL_SEARCH_QUALIFIERS.find((q) => lower.startsWith(q.prefix)); - - if (!matchedQualifier) { - return null; - } - - const rest = value.substring(matchedQualifier.prefix.length); - if (rest.length === 0) { - return null; // prefix only, incomplete token - } - - return `${matchedQualifier.prefix}${rest}`; -} - export function filterNotificationBySearchTerm( notification: Notification, token: string, ): boolean { - const parsed = parseSearchToken(token); + const parsed = parseSearchInput(token); + if (!parsed) { return false; } From 02aca71b63d5d057393fb37fd738f3779175a31e Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:43:02 -0400 Subject: [PATCH 23/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../filters/SearchFilterSuggestions.test.tsx | 72 ++ .../SearchFilterSuggestions.test.tsx.snap | 784 ++++++++++++++++++ 2 files changed, 856 insertions(+) create mode 100644 src/renderer/components/filters/SearchFilterSuggestions.test.tsx create mode 100644 src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap diff --git a/src/renderer/components/filters/SearchFilterSuggestions.test.tsx b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx new file mode 100644 index 000000000..bb390c1ad --- /dev/null +++ b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx @@ -0,0 +1,72 @@ +import { render } from '@testing-library/react'; + +import { SearchFilterSuggestions } from './SearchFilterSuggestions'; + +describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { + const mockOnClose = jest.fn(); + + it('should render itself & its children - closed', () => { + const tree = render( + <SearchFilterSuggestions + inputValue={''} + isDetailedNotificationsEnabled={false} + onClose={mockOnClose} + open={false} + />, + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - open', () => { + const tree = render( + <SearchFilterSuggestions + inputValue={''} + isDetailedNotificationsEnabled={false} + onClose={mockOnClose} + open={true} + />, + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - open with detailed enabled', () => { + const tree = render( + <SearchFilterSuggestions + inputValue={''} + isDetailedNotificationsEnabled={true} + onClose={mockOnClose} + open={true} + />, + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - input token invalid', () => { + const tree = render( + <SearchFilterSuggestions + inputValue={'invalid'} + isDetailedNotificationsEnabled={false} + onClose={mockOnClose} + open={true} + />, + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - input token valid', () => { + const tree = render( + <SearchFilterSuggestions + inputValue={'repo:'} + isDetailedNotificationsEnabled={false} + onClose={mockOnClose} + open={true} + />, + ); + + expect(tree).toMatchSnapshot(); + }); +}); 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..53881226b --- /dev/null +++ b/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap @@ -0,0 +1,784 @@ +// 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": <body> + <div /> + </body>, + "container": <div />, + "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": <body> + <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + Please use one of the supported filters [ + org, repo + ] + </span> + </div> + </div> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + Please use one of the supported filters [ + org, repo + ] + </span> + </div> + </div> + </div> + </div> + </div>, + "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": <body> + <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div>, + "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": <body> + <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + org: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by organization owner + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + org: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by organization owner + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div>, + "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": <body> + <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + author: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by notification author + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + org: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by organization owner + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="Popover-sc-q9r75g-0 kZtvVv caret-pos--false" + open="" + > + <div + class="Popover__PopoverContent-sc-q9r75g-1 WOpWG" + sx="[object Object]" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="condensed" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + author: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by notification author + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + org: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by organization owner + </span> + </div> + </div> + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + repo: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by repository full name + </span> + </div> + </div> + </div> + </div> + </div> + </div>, + "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], +} +`; From 3d507f5c1eb3d22eff596d2a0c32c98f751d160c Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 14:52:25 -0400 Subject: [PATCH 24/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../components/filters/SearchFilter.tsx | 2 - .../filters/SearchFilterSuggestions.test.tsx | 103 +++++++++++++----- .../filters/SearchFilterSuggestions.tsx | 10 +- .../components/filters/TokenSearchInput.tsx | 3 - .../SearchFilterSuggestions.test.tsx.snap | 48 ++++++++ 5 files changed, 127 insertions(+), 39 deletions(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index a5c7cc27a..b3c741e53 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -120,7 +120,6 @@ export const SearchFilter: FC = () => { <TokenSearchInput icon={CheckCircleFillIcon} iconColorClass={IconColor.GREEN} - isDetailedNotificationsEnabled={settings.detailedNotifications} label="Include" onAdd={addIncludeSearchToken} onRemove={removeIncludeSearchToken} @@ -131,7 +130,6 @@ export const SearchFilter: FC = () => { <TokenSearchInput icon={NoEntryFillIcon} iconColorClass={IconColor.RED} - isDetailedNotificationsEnabled={settings.detailedNotifications} label="Exclude" onAdd={addExcludeSearchToken} onRemove={removeExcludeSearchToken} diff --git a/src/renderer/components/filters/SearchFilterSuggestions.test.tsx b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx index bb390c1ad..092d1de03 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.test.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.test.tsx @@ -1,5 +1,8 @@ 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', () => { @@ -7,12 +10,20 @@ describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { it('should render itself & its children - closed', () => { const tree = render( - <SearchFilterSuggestions - inputValue={''} - isDetailedNotificationsEnabled={false} - onClose={mockOnClose} - open={false} - />, + <AppContext.Provider + value={{ + settings: { + ...mockSettings, + detailedNotifications: false, + } as SettingsState, + }} + > + <SearchFilterSuggestions + inputValue={''} + onClose={mockOnClose} + open={false} + /> + </AppContext.Provider>, ); expect(tree).toMatchSnapshot(); @@ -20,12 +31,20 @@ describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { it('should render itself & its children - open', () => { const tree = render( - <SearchFilterSuggestions - inputValue={''} - isDetailedNotificationsEnabled={false} - onClose={mockOnClose} - open={true} - />, + <AppContext.Provider + value={{ + settings: { + ...mockSettings, + detailedNotifications: true, + } as SettingsState, + }} + > + <SearchFilterSuggestions + inputValue={''} + onClose={mockOnClose} + open={true} + /> + </AppContext.Provider>, ); expect(tree).toMatchSnapshot(); @@ -33,12 +52,20 @@ describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { it('should render itself & its children - open with detailed enabled', () => { const tree = render( - <SearchFilterSuggestions - inputValue={''} - isDetailedNotificationsEnabled={true} - onClose={mockOnClose} - open={true} - />, + <AppContext.Provider + value={{ + settings: { + ...mockSettings, + detailedNotifications: true, + } as SettingsState, + }} + > + <SearchFilterSuggestions + inputValue={''} + onClose={mockOnClose} + open={true} + /> + </AppContext.Provider>, ); expect(tree).toMatchSnapshot(); @@ -46,12 +73,20 @@ describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { it('should render itself & its children - input token invalid', () => { const tree = render( - <SearchFilterSuggestions - inputValue={'invalid'} - isDetailedNotificationsEnabled={false} - onClose={mockOnClose} - open={true} - />, + <AppContext.Provider + value={{ + settings: { + ...mockSettings, + detailedNotifications: false, + } as SettingsState, + }} + > + <SearchFilterSuggestions + inputValue={'invalid'} + onClose={mockOnClose} + open={true} + /> + </AppContext.Provider>, ); expect(tree).toMatchSnapshot(); @@ -59,12 +94,20 @@ describe('renderer/components/filters/SearchFilterSuggestions.tsx', () => { it('should render itself & its children - input token valid', () => { const tree = render( - <SearchFilterSuggestions - inputValue={'repo:'} - isDetailedNotificationsEnabled={false} - onClose={mockOnClose} - open={true} - />, + <AppContext.Provider + value={{ + settings: { + ...mockSettings, + detailedNotifications: false, + } as SettingsState, + }} + > + <SearchFilterSuggestions + inputValue={'repo:'} + onClose={mockOnClose} + open={true} + /> + </AppContext.Provider>, ); expect(tree).toMatchSnapshot(); diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 9e656b79f..2bc7bd03d 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react'; +import { useContext, type FC } from 'react'; import { Box, Popover, Stack, Text } from '@primer/react'; @@ -9,26 +9,28 @@ import { BASE_SEARCH_QUALIFIERS, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; +import { AppContext } from '../../context/App'; interface SearchFilterSuggestionsProps { open: boolean; inputValue: string; - isDetailedNotificationsEnabled: boolean; onClose: () => void; } export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({ open, inputValue, - isDetailedNotificationsEnabled, onClose, }) => { + + const { settings } = useContext(AppContext); + if (!open) { return null; } const lower = inputValue.toLowerCase(); - const base = isDetailedNotificationsEnabled + const base = settings.detailedNotifications ? ALL_SEARCH_QUALIFIERS : BASE_SEARCH_QUALIFIERS; const suggestions = base.filter( diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index ee701f0a9..7ecb165e0 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -15,7 +15,6 @@ interface TokenSearchInputProps { iconColorClass: string; tokens: readonly SearchToken[]; // raw token strings showSuggestionsOnFocusIfEmpty: boolean; - isDetailedNotificationsEnabled: boolean; onAdd: (token: SearchToken) => void; onRemove: (token: SearchToken) => void; } @@ -28,7 +27,6 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ iconColorClass, tokens, showSuggestionsOnFocusIfEmpty, - isDetailedNotificationsEnabled, onAdd, onRemove, }) => { @@ -117,7 +115,6 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ /> <SearchFilterSuggestions inputValue={inputValue} - isDetailedNotificationsEnabled={isDetailedNotificationsEnabled} onClose={() => setShowSuggestions(false)} open={showSuggestions} /> diff --git a/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap b/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap index 53881226b..bd80ee8aa 100644 --- a/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap +++ b/src/renderer/components/filters/__snapshots__/SearchFilterSuggestions.test.tsx.snap @@ -359,6 +359,30 @@ exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render i data-padding="none" data-wrap="nowrap" > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + author: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by notification author + </span> + </div> + </div> <div class="Box-sc-g0xbh4-0" > @@ -430,6 +454,30 @@ exports[`renderer/components/filters/SearchFilterSuggestions.tsx should render i data-padding="none" data-wrap="nowrap" > + <div + class="Box-sc-g0xbh4-0" + > + <div + class="Stack__StyledStack-sc-x3xa2i-0 kzRZTJ" + data-align="stretch" + data-direction="vertical" + data-gap="none" + data-justify="start" + data-padding="none" + data-wrap="nowrap" + > + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs font-semibold" + > + author: + </span> + <span + class="Text-sc-17v1xeu-0 ezOLpc text-xs opacity-90" + > + filter by notification author + </span> + </div> + </div> <div class="Box-sc-g0xbh4-0" > From a9ea65c05f144ce96d473a22146e9810125537e4 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 15:00:12 -0400 Subject: [PATCH 25/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- .../components/filters/SearchFilterSuggestions.tsx | 5 ++--- src/renderer/components/filters/TokenSearchInput.tsx | 4 +--- src/renderer/utils/notifications/filters/search.ts | 7 +++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 2bc7bd03d..6bcf0ebd8 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -1,7 +1,8 @@ -import { useContext, type FC } from 'react'; +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 { @@ -9,7 +10,6 @@ import { BASE_SEARCH_QUALIFIERS, SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; -import { AppContext } from '../../context/App'; interface SearchFilterSuggestionsProps { open: boolean; @@ -22,7 +22,6 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({ inputValue, onClose, }) => { - const { settings } = useContext(AppContext); if (!open) { diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index 7ecb165e0..e80c39321 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -101,9 +101,7 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({ }} onKeyDown={onKeyDown} onTokenRemove={(id) => { - const token = tokenItems.find((t) => t.id === id)?.text as - | SearchToken - | undefined; + const token = tokenItems.find((t) => t.id === id)?.text; if (token) { onRemove(token); diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index 64cc73d2e..16e8ce87a 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -8,20 +8,19 @@ const SEARCH_QUALIFIERS = { prefix: 'author:', description: 'filter by notification author', requiresDetailsNotifications: true, - extract: (n: Notification) => n.subject?.user?.login as string | undefined, + extract: (n: Notification) => n.subject?.user?.login, }, org: { prefix: 'org:', description: 'filter by organization owner', requiresDetailsNotifications: false, - extract: (n: Notification) => - n.repository?.owner?.login as string | undefined, + 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 string | undefined, + extract: (n: Notification) => n.repository?.full_name, }, } as const; From ade4e3cc4d142e05b3231b9f09770e7d1623c549 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Mon, 8 Sep 2025 15:05:35 -0400 Subject: [PATCH 26/26] feat: search notifications Signed-off-by: Adam Setch <adam.setch@outlook.com> --- src/renderer/components/filters/SearchFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index b3c741e53..0183b63e8 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -58,7 +58,7 @@ export const SearchFilter: FC = () => { }; const [excludeSearchTokens, setExcludeSearchTokens] = useState<SearchToken[]>( - settings.filterExcludeSearchTokens as SearchToken[], + settings.filterExcludeSearchTokens, ); const addExcludeSearchToken = (value: string) => {