diff --git a/package.json b/package.json index 99401a8e4..af5ff2796 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,8 @@ "vite-plugin-electron": "0.29.0", "vite-plugin-electron-renderer": "0.14.6", "vite-plugin-static-copy": "3.2.0", - "vitest": "4.0.18" + "vitest": "4.0.18", + "zustand": "5.0.11" }, "packageManager": "pnpm@10.30.0", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b9f526a..6316c3115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: vitest: specifier: 4.0.18 version: 4.0.18(@types/node@24.10.13)(@vitest/ui@4.0.18)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + zustand: + specifier: 5.0.11 + version: 5.0.11(@types/react@19.2.14)(react@19.2.4) packages: @@ -4133,6 +4136,24 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: 7zip-bin@5.2.0: {} @@ -8271,3 +8292,8 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 60295a52d..484651a00 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -1,10 +1,16 @@ import '@testing-library/jest-dom/vitest'; +import { useFiltersStore } from '../stores'; + // Sets timezone to UTC for consistent date/time in tests and snapshots process.env.TZ = 'UTC'; -// Mock OAuth client ID and secret -process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123'; +/** + * Reset stores + */ +beforeEach(() => { + useFiltersStore.getState().reset(); +}); /** * Gitify context bridge API diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index b0f9d02bc..66246ad44 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -4,7 +4,6 @@ import { type AppearanceSettingsState, type AuthState, FetchType, - type FilterSettingsState, type GitifyState, GroupBy, type NotificationSettingsState, @@ -66,21 +65,11 @@ const mockSystemSettings: SystemSettingsState = { openAtStartup: false, }; -const mockFilters: FilterSettingsState = { - filterUserTypes: [], - filterIncludeSearchTokens: [], - filterExcludeSearchTokens: [], - filterSubjectTypes: [], - filterStates: [], - filterReasons: [], -}; - export const mockSettings: SettingsState = { ...mockAppearanceSettings, ...mockNotificationSettings, ...mockTraySettings, ...mockSystemSettings, - ...mockFilters, }; export const mockState: GitifyState = { diff --git a/src/renderer/components/AllRead.test.tsx b/src/renderer/components/AllRead.test.tsx index a666af8ab..e1ae22955 100644 --- a/src/renderer/components/AllRead.test.tsx +++ b/src/renderer/components/AllRead.test.tsx @@ -6,6 +6,7 @@ import { } from '../__helpers__/test-utils'; import { mockSettings } from '../__mocks__/state-mocks'; +import { useFiltersStore } from '../stores'; import { AllRead } from './AllRead'; describe('renderer/components/AllRead.tsx', () => { @@ -20,12 +21,6 @@ describe('renderer/components/AllRead.tsx', () => { tree = renderWithAppContext(, { settings: { ...mockSettings, - filterReasons: [], - filterStates: [], - filterSubjectTypes: [], - filterUserTypes: [], - filterIncludeSearchTokens: [], - filterExcludeSearchTokens: [], }, }); }); @@ -34,13 +29,14 @@ describe('renderer/components/AllRead.tsx', () => { }); it('should render itself & its children - with filters', async () => { + useFiltersStore.setState({ reasons: ['author'] }); + let tree: ReturnType | null = null; await act(async () => { tree = renderWithAppContext(, { settings: { ...mockSettings, - filterReasons: ['author'], }, }); }); diff --git a/src/renderer/components/AllRead.tsx b/src/renderer/components/AllRead.tsx index 4fc2e7bdd..cbfe8a475 100644 --- a/src/renderer/components/AllRead.tsx +++ b/src/renderer/components/AllRead.tsx @@ -2,11 +2,9 @@ import { type FC, useMemo } from 'react'; import { Constants } from '../constants'; -import { useAppContext } from '../hooks/useAppContext'; - import { EmojiSplash } from './layout/EmojiSplash'; -import { hasActiveFilters } from '../utils/notifications/filters/filter'; +import { useFiltersStore } from '../stores'; interface AllReadProps { fullHeight?: boolean; @@ -15,9 +13,7 @@ interface AllReadProps { export const AllRead: FC = ({ fullHeight = true, }: AllReadProps) => { - const { settings } = useAppContext(); - - const hasFilters = hasActiveFilters(settings); + const hasFilters = useFiltersStore((s) => s.hasActiveFilters()); const emoji = useMemo( () => diff --git a/src/renderer/components/Sidebar.test.tsx b/src/renderer/components/Sidebar.test.tsx index dcf4459c0..a4a4ed17e 100644 --- a/src/renderer/components/Sidebar.test.tsx +++ b/src/renderer/components/Sidebar.test.tsx @@ -6,6 +6,7 @@ import { renderWithAppContext } from '../__helpers__/test-utils'; import { mockMultipleAccountNotifications } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; +import { useFiltersStore } from '../stores'; import * as comms from '../utils/comms'; import { Sidebar } from './Sidebar'; @@ -185,15 +186,14 @@ describe('renderer/components/Sidebar.tsx', () => { }); it('highlight filters sidebar if any are saved', () => { + useFiltersStore.setState({ reasons: ['assign'] }); + renderWithAppContext( , { - settings: { - ...mockSettings, - filterReasons: ['assign'], - }, + settings: mockSettings, }, ); diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index d63150fcc..e84dd7b55 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -18,7 +18,7 @@ import { APPLICATION } from '../../shared/constants'; import { useAppContext } from '../hooks/useAppContext'; import { useShortcutActions } from '../hooks/useShortcutActions'; -import { hasActiveFilters } from '../utils/notifications/filters/filter'; +import { useFiltersStore } from '../stores'; import { LogoIcon } from './icons/LogoIcon'; export const Sidebar: FC = () => { @@ -32,6 +32,8 @@ export const Sidebar: FC = () => { const { shortcuts } = useShortcutActions(); + const hasFilters = useFiltersStore((s) => s.hasActiveFilters()); + const isLoading = status === 'loading'; return ( @@ -97,7 +99,7 @@ export const Sidebar: FC = () => { onClick={() => shortcuts.filters.action()} size="small" tooltipDirection="e" - variant={hasActiveFilters(settings) ? 'primary' : 'invisible'} + variant={hasFilters ? 'primary' : 'invisible'} /> )} diff --git a/src/renderer/components/filters/FilterSection.test.tsx b/src/renderer/components/filters/FilterSection.test.tsx index 7ea890778..a740403e1 100644 --- a/src/renderer/components/filters/FilterSection.test.tsx +++ b/src/renderer/components/filters/FilterSection.test.tsx @@ -7,14 +7,23 @@ import { renderWithAppContext } from '../../__helpers__/test-utils'; import { mockMultipleAccountNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; +import { useFiltersStore } from '../../stores'; import { stateFilter } from '../../utils/notifications/filters'; import { FilterSection } from './FilterSection'; describe('renderer/components/filters/FilterSection.tsx', () => { - const updateFilterMock = vi.fn(); - const mockFilter = stateFilter; - const mockFilterSetting = 'filterStates'; + const mockFilterSetting = 'states'; + + let updateFilterSpy: ReturnType; + + beforeEach(() => { + updateFilterSpy = vi.spyOn(useFiltersStore.getState(), 'updateFilter'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); describe('should render itself & its children', () => { it('with detailed notifications enabled', () => { @@ -77,29 +86,25 @@ describe('renderer/components/filters/FilterSection.tsx', () => { title={'FilterSectionTitle'} />, { - settings: { - ...mockSettings, - filterStates: [], - }, - updateFilter: updateFilterMock, + settings: mockSettings, }, ); }); await userEvent.click(screen.getByLabelText('Open')); - expect(updateFilterMock).toHaveBeenCalledWith( + expect(updateFilterSpy).toHaveBeenCalledWith( mockFilterSetting, 'open', true, ); - - expect( - screen.getByLabelText('Open').parentNode.parentNode, - ).toMatchSnapshot(); }); it('should be able to toggle filter value - some filters already set', async () => { + useFiltersStore.setState({ + states: ['open'], + }); + await act(async () => { renderWithAppContext( { title={'FilterSectionTitle'} />, { - settings: { - ...mockSettings, - filterStates: ['open'], - }, - updateFilter: updateFilterMock, + settings: mockSettings, }, ); }); await userEvent.click(screen.getByLabelText('Closed')); - expect(updateFilterMock).toHaveBeenCalledWith( + expect(updateFilterSpy).toHaveBeenCalledWith( mockFilterSetting, 'closed', true, ); - - expect( - screen.getByLabelText('Closed').parentNode.parentNode, - ).toMatchSnapshot(); }); }); diff --git a/src/renderer/components/filters/FilterSection.tsx b/src/renderer/components/filters/FilterSection.tsx index 9a80ab564..c5980199d 100644 --- a/src/renderer/components/filters/FilterSection.tsx +++ b/src/renderer/components/filters/FilterSection.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { memo, type ReactNode, useMemo } from 'react'; import type { Icon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; @@ -8,22 +8,21 @@ import { useAppContext } from '../../hooks/useAppContext'; import { Checkbox } from '../fields/Checkbox'; import { Title } from '../primitives/Title'; -import type { FilterSettingsState, FilterSettingsValue } from '../../types'; - +import { type FiltersState, useFiltersStore } from '../../stores'; import type { Filter } from '../../utils/notifications/filters'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; -export interface FilterSectionProps { +export interface FilterSectionProps { id: string; title: string; icon: Icon; - filter: Filter; - filterSetting: keyof FilterSettingsState; + filter: Filter; + filterSetting: K; tooltip?: ReactNode; layout?: 'horizontal' | 'vertical'; } -export const FilterSection = ({ +const FilterSectionComponent = ({ id, title, icon, @@ -31,8 +30,23 @@ export const FilterSection = ({ filterSetting, tooltip, layout = 'vertical', -}: FilterSectionProps) => { - const { updateFilter, settings, notifications } = useAppContext(); +}: FilterSectionProps) => { + const { notifications, settings } = useAppContext(); + const updateFilter = useFiltersStore((s) => s.updateFilter); + + // Subscribe to the specific filter state so component re-renders when filters change + useFiltersStore((s) => s[filterSetting]); + + // Memoize filter counts to avoid recalculating on every render + const filterCounts = useMemo(() => { + const counts = new Map(); + for (const type of Object.keys( + filter.FILTER_TYPES, + ) as FiltersState[K][number][]) { + counts.set(type, filter.getFilterCount(notifications, type)); + } + return counts; + }, [notifications, filter]); return (
@@ -56,7 +70,7 @@ export const FilterSection = ({ direction={layout} gap={layout === 'horizontal' ? 'normal' : 'condensed'} > - {(Object.keys(filter.FILTER_TYPES) as T[]) + {(Object.keys(filter.FILTER_TYPES) as FiltersState[K][number][]) .sort((a, b) => filter .getTypeDetails(a) @@ -69,8 +83,8 @@ export const FilterSection = ({ const typeDetails = filter.getTypeDetails(type); const typeTitle = typeDetails.title; const typeDescription = typeDetails.description; - const isChecked = filter.isFilterSet(settings, type); - const count = filter.getFilterCount(notifications, type); + const isChecked = filter.isFilterSet(type); + const count = filterCounts.get(type) ?? 0; return ( ({
); }; + +// Memoize the component to prevent unnecessary re-renders +export const FilterSection = memo( + FilterSectionComponent, +) as typeof FilterSectionComponent; diff --git a/src/renderer/components/filters/ReasonFilter.tsx b/src/renderer/components/filters/ReasonFilter.tsx index a9a5f2292..d3e3b5a30 100644 --- a/src/renderer/components/filters/ReasonFilter.tsx +++ b/src/renderer/components/filters/ReasonFilter.tsx @@ -10,7 +10,7 @@ export const ReasonFilter: FC = () => { return ( { - const updateFilterMock = vi.fn(); + let updateFilterSpy: ReturnType; + + beforeEach(() => { + updateFilterSpy = vi.spyOn(useFiltersStore.getState(), 'updateFilter'); + }); afterEach(() => { vi.clearAllMocks(); @@ -13,41 +18,35 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { describe('Include Search Tokens', () => { it('adds include actor token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const includeInput = screen.getByTitle('Include searches'); fireEvent.change(includeInput, { target: { value: 'author:octocat' } }); fireEvent.keyDown(includeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterIncludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'includeSearchTokens', 'author:octocat', true, ); }); it('adds include org token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const includeInput = screen.getByTitle('Include searches'); fireEvent.change(includeInput, { target: { value: 'org:gitify-app' } }); fireEvent.keyDown(includeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterIncludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'includeSearchTokens', 'org:gitify-app', true, ); }); it('adds include repo token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const includeInput = screen.getByTitle('Include searches'); fireEvent.change(includeInput, { @@ -55,17 +54,15 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { }); fireEvent.keyDown(includeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterIncludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'includeSearchTokens', 'repo:gitify-app/gitify', true, ); }); it('prevent unrecognized include prefixes', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const includeInput = screen.getByTitle('Include searches'); fireEvent.change(includeInput, { @@ -73,47 +70,41 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { }); fireEvent.keyDown(includeInput, { key: 'Enter' }); - expect(updateFilterMock).not.toHaveBeenCalledWith(); + expect(updateFilterSpy).not.toHaveBeenCalledWith(); }); }); describe('Exclude Search Tokens', () => { it('adds exclude actor token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const includeInput = screen.getByTitle('Exclude searches'); fireEvent.change(includeInput, { target: { value: 'author:octocat' } }); fireEvent.keyDown(includeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterExcludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'excludeSearchTokens', 'author:octocat', true, ); }); it('adds exclude org token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const excludeInput = screen.getByTitle('Exclude searches'); fireEvent.change(excludeInput, { target: { value: 'org:gitify-app' } }); fireEvent.keyDown(excludeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterExcludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'excludeSearchTokens', 'org:gitify-app', true, ); }); it('adds exclude repo token with prefix', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const excludeInput = screen.getByTitle('Exclude searches'); fireEvent.change(excludeInput, { @@ -121,17 +112,15 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { }); fireEvent.keyDown(excludeInput, { key: 'Enter' }); - expect(updateFilterMock).toHaveBeenCalledWith( - 'filterExcludeSearchTokens', + expect(updateFilterSpy).toHaveBeenCalledWith( + 'excludeSearchTokens', 'repo:gitify-app/gitify', true, ); }); it('prevent unrecognized exclude prefixes', () => { - renderWithAppContext(, { - updateFilter: updateFilterMock, - }); + renderWithAppContext(); const excludeInput = screen.getByTitle('Exclude searches'); fireEvent.change(excludeInput, { @@ -139,7 +128,7 @@ describe('renderer/components/filters/SearchFilter.tsx', () => { }); fireEvent.keyDown(excludeInput, { key: 'Enter' }); - expect(updateFilterMock).not.toHaveBeenCalledWith(); + expect(updateFilterSpy).not.toHaveBeenCalledWith(); }); }); }); diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index 0ec054b3c..e5f3b9212 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from 'react'; +import type { FC } from 'react'; import { CheckCircleFillIcon, @@ -16,6 +16,7 @@ import { Title } from '../primitives/Title'; import { IconColor, type SearchToken, Size } from '../../types'; +import { useFiltersStore } from '../../stores'; import { cn } from '../../utils/cn'; import { hasExcludeSearchFilters, @@ -25,30 +26,18 @@ import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificat import { TokenSearchInput } from './TokenSearchInput'; export const SearchFilter: FC = () => { - const { updateFilter, settings } = useAppContext(); + const { settings } = useAppContext(); - // biome-ignore lint/correctness/useExhaustiveDependencies: only run on search filter changes - useEffect(() => { - if (!hasIncludeSearchFilters(settings)) { - setIncludeSearchTokens([]); - } - - if (!hasExcludeSearchFilters(settings)) { - setExcludeSearchTokens([]); - } - }, [settings.filterIncludeSearchTokens, settings.filterExcludeSearchTokens]); - - const [includeSearchTokens, setIncludeSearchTokens] = useState( - settings.filterIncludeSearchTokens, - ); + const updateFilter = useFiltersStore((s) => s.updateFilter); + const includeSearchTokens = useFiltersStore((s) => s.includeSearchTokens); + const excludeSearchTokens = useFiltersStore((s) => s.excludeSearchTokens); const addIncludeSearchToken = (value: string) => { if (!value || includeSearchTokens.includes(value as SearchToken)) { return; } - setIncludeSearchTokens([...includeSearchTokens, value as SearchToken]); - updateFilter('filterIncludeSearchTokens', value as SearchToken, true); + updateFilter('includeSearchTokens', value as SearchToken, true); }; const removeIncludeSearchToken = (token: SearchToken) => { @@ -56,21 +45,15 @@ export const SearchFilter: FC = () => { return; } - updateFilter('filterIncludeSearchTokens', token, false); - setIncludeSearchTokens(includeSearchTokens.filter((t) => t !== token)); + updateFilter('includeSearchTokens', token, false); }; - const [excludeSearchTokens, setExcludeSearchTokens] = useState( - settings.filterExcludeSearchTokens, - ); - const addExcludeSearchToken = (value: string) => { if (!value || excludeSearchTokens.includes(value as SearchToken)) { return; } - setExcludeSearchTokens([...excludeSearchTokens, value as SearchToken]); - updateFilter('filterExcludeSearchTokens', value as SearchToken, true); + updateFilter('excludeSearchTokens', value as SearchToken, true); }; const removeExcludeSearchToken = (token: SearchToken) => { @@ -78,8 +61,7 @@ export const SearchFilter: FC = () => { return; } - updateFilter('filterExcludeSearchTokens', token, false); - setExcludeSearchTokens(excludeSearchTokens.filter((t) => t !== token)); + updateFilter('excludeSearchTokens', token, false); }; return ( @@ -126,7 +108,7 @@ export const SearchFilter: FC = () => { label="Include" onAdd={addIncludeSearchToken} onRemove={removeIncludeSearchToken} - showSuggestionsOnFocusIfEmpty={!hasIncludeSearchFilters(settings)} + showSuggestionsOnFocusIfEmpty={!hasIncludeSearchFilters()} tokens={includeSearchTokens} /> @@ -136,7 +118,7 @@ export const SearchFilter: FC = () => { label="Exclude" onAdd={addExcludeSearchToken} onRemove={removeExcludeSearchToken} - showSuggestionsOnFocusIfEmpty={!hasExcludeSearchFilters(settings)} + showSuggestionsOnFocusIfEmpty={!hasExcludeSearchFilters()} tokens={excludeSearchTokens} /> diff --git a/src/renderer/components/filters/StateFilter.tsx b/src/renderer/components/filters/StateFilter.tsx index 54869ef37..83d1797d3 100644 --- a/src/renderer/components/filters/StateFilter.tsx +++ b/src/renderer/components/filters/StateFilter.tsx @@ -10,7 +10,7 @@ export const StateFilter: FC = () => { return ( { return ( { return ( should be able to toggle filter value - none already set 1`] = ` -
-
- - - - - 0 - -
-
- - - - 0 - -
-
- - - - 0 - -
-
- - - - - 0 - -
-
- - - - - 0 - -
-
-`; - -exports[`renderer/components/filters/FilterSection.tsx > should be able to toggle filter value - some filters already set 1`] = ` -
-
- - - - - 0 - -
-
- - - - 0 - -
-
- - - - 0 - -
-
- - - - - 0 - -
-
- - - - - 0 - -
-
-`; - exports[`renderer/components/filters/FilterSection.tsx > should render itself & its children > with detailed notifications disabled 1`] = `
{ const unsubscribeNotificationMock = vi.fn(); const removeAccountNotificationsMock = vi.fn(); - const saveStateSpy = vi - .spyOn(storage, 'saveState') - .mockImplementation(vi.fn()); - beforeEach(() => { vi.useFakeTimers(); vi.mocked(useNotifications).mockReturnValue({ @@ -224,67 +220,6 @@ describe('renderer/context/App.tsx', () => { }); }); - describe('filter methods', () => { - it('should call updateFilter - checked', async () => { - const getContext = renderWithContext(); - - act(() => { - getContext().updateFilter('filterReasons', 'assign', true); - }); - - expect(saveStateSpy).toHaveBeenCalledWith({ - auth: { - accounts: [], - } as AuthState, - settings: { - ...defaultSettings, - filterReasons: ['assign'], - } as SettingsState, - }); - }); - - it('should call updateFilter - unchecked', async () => { - const getContext = renderWithContext(); - - act(() => { - getContext().updateFilter('filterReasons', 'assign', false); - }); - - expect(saveStateSpy).toHaveBeenCalledWith({ - auth: { - accounts: [], - } as AuthState, - settings: { - ...defaultSettings, - filterReasons: [], - } as SettingsState, - }); - }); - - it('should clear filters back to default', async () => { - const getContext = renderWithContext(); - - act(() => { - getContext().clearFilters(); - }); - - expect(saveStateSpy).toHaveBeenCalledWith({ - auth: { - accounts: [], - } as AuthState, - settings: { - ...mockSettings, - filterIncludeSearchTokens: defaultSettings.filterIncludeSearchTokens, - filterExcludeSearchTokens: defaultSettings.filterExcludeSearchTokens, - filterUserTypes: defaultSettings.filterUserTypes, - filterSubjectTypes: defaultSettings.filterSubjectTypes, - filterStates: defaultSettings.filterStates, - filterReasons: defaultSettings.filterReasons, - }, - }); - }); - }); - describe('authentication functions', () => { const addAccountSpy = vi .spyOn(authUtils, 'addAccount') diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index cd38d6c6f..cd996ba53 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -19,10 +19,6 @@ import type { Account, AccountNotifications, AuthState, - ConfigSettingsState, - ConfigSettingsValue, - FilterSettingsState, - FilterSettingsValue, GitifyError, GitifyNotification, Hostname, @@ -38,6 +34,7 @@ import type { LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; +import { useFiltersStore } from '../stores'; import { fetchAuthenticatedUserDetails } from '../utils/api/client'; import { clearOctokitClientCache } from '../utils/api/octokit'; import { @@ -70,11 +67,7 @@ import { } from '../utils/theme'; import { setTrayIconColorAndTitle } from '../utils/tray'; import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/zoom'; -import { - defaultAuth, - defaultFilterSettings, - defaultSettings, -} from './defaults'; +import { defaultAuth, defaultSettings } from './defaults'; export interface AppContextState { auth: AuthState; @@ -114,17 +107,8 @@ export interface AppContextState { unsubscribeNotification: (notification: GitifyNotification) => Promise; settings: SettingsState; - clearFilters: () => void; resetSettings: () => void; - updateSetting: ( - name: keyof ConfigSettingsState, - value: ConfigSettingsValue, - ) => void; - updateFilter: ( - name: keyof FilterSettingsState, - value: FilterSettingsValue, - checked: boolean, - ) => void; + updateSetting: (name: keyof SettingsState, value: SettingsValue) => void; } export const AppContext = createContext | undefined>( @@ -166,6 +150,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { unsubscribeNotification, } = useNotifications(); + const includeSearchTokens = useFiltersStore((s) => s.includeSearchTokens); + const excludeSearchTokens = useFiltersStore((s) => s.excludeSearchTokens); + const userTypes = useFiltersStore((s) => s.userTypes); + const subjectTypes = useFiltersStore((s) => s.subjectTypes); + const states = useFiltersStore((s) => s.states); + const reasons = useFiltersStore((s) => s.reasons); + const persistAuth = useCallback( (nextAuth: AuthState) => { setAuth(nextAuth); @@ -236,12 +227,12 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [ auth.accounts.length, settings.participating, - settings.filterIncludeSearchTokens, - settings.filterExcludeSearchTokens, - settings.filterUserTypes, - settings.filterSubjectTypes, - settings.filterStates, - settings.filterReasons, + includeSearchTokens, + excludeSearchTokens, + userTypes, + subjectTypes, + states, + reasons, ]); useIntervalTimer( @@ -333,14 +324,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }); }, []); - const clearFilters = useCallback(() => { - setSettings((prevSettings) => { - const newSettings = { ...prevSettings, ...defaultFilterSettings }; - saveState({ auth, settings: newSettings }); - return newSettings; - }); - }, [auth]); - const resetSettings = useCallback(() => { setSettings(() => { saveState({ auth, settings: defaultSettings }); @@ -359,21 +342,6 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth], ); - const updateFilter = useCallback( - ( - name: keyof FilterSettingsState, - value: FilterSettingsValue, - checked: boolean, - ) => { - const updatedFilters = checked - ? [...settings[name], value] - : settings[name].filter((item) => item !== value); - - updateSetting(name, updatedFilters); - }, - [updateSetting, settings], - ); - // Global window zoom handler / listener // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update on settings.zoomPercentage changes useEffect(() => { @@ -551,10 +519,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { unsubscribeNotification: unsubscribeNotificationWithAccounts, settings, - clearFilters, resetSettings, updateSetting, - updateFilter, }), [ auth, @@ -583,10 +549,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { unsubscribeNotificationWithAccounts, settings, - clearFilters, resetSettings, updateSetting, - updateFilter, ], ); diff --git a/src/renderer/context/defaults.ts b/src/renderer/context/defaults.ts index 61f622aeb..1bbab9848 100644 --- a/src/renderer/context/defaults.ts +++ b/src/renderer/context/defaults.ts @@ -3,9 +3,7 @@ import { Constants } from '../constants'; import { type AppearanceSettingsState, type AuthState, - type ConfigSettingsState, FetchType, - type FilterSettingsState, GroupBy, type NotificationSettingsState, OpenPreference, @@ -58,23 +56,9 @@ const defaultSystemSettings: SystemSettingsState = { openAtStartup: false, }; -export const defaultFilterSettings: FilterSettingsState = { - filterUserTypes: [], - filterIncludeSearchTokens: [], - filterExcludeSearchTokens: [], - filterSubjectTypes: [], - filterStates: [], - filterReasons: [], -}; - -export const defaultConfigSettings: ConfigSettingsState = { +export const defaultSettings: SettingsState = { ...defaultAppearanceSettings, ...defaultNotificationSettings, ...defaultTraySettings, ...defaultSystemSettings, }; - -export const defaultSettings: SettingsState = { - ...defaultConfigSettings, - ...defaultFilterSettings, -}; diff --git a/src/renderer/routes/Filters.test.tsx b/src/renderer/routes/Filters.test.tsx index 0fd090a71..b8dcccfe3 100644 --- a/src/renderer/routes/Filters.test.tsx +++ b/src/renderer/routes/Filters.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { renderWithAppContext } from '../__helpers__/test-utils'; +import { type FiltersStore, useFiltersStore } from '../stores'; import { FiltersRoute } from './Filters'; const navigateMock = vi.fn(); @@ -12,9 +13,15 @@ vi.mock('react-router-dom', async () => ({ })); describe('renderer/routes/Filters.tsx', () => { - const clearFiltersMock = vi.fn(); const fetchNotificationsMock = vi.fn(); + let resetSpy: ReturnType; + + beforeEach(() => { + // spy the actions on the real store + resetSpy = vi.spyOn(useFiltersStore.getState() as FiltersStore, 'reset'); + }); + afterEach(() => { vi.clearAllMocks(); }); @@ -46,14 +53,12 @@ describe('renderer/routes/Filters.tsx', () => { describe('Footer section', () => { it('should clear filters', async () => { await act(async () => { - renderWithAppContext(, { - clearFilters: clearFiltersMock, - }); + renderWithAppContext(); }); await userEvent.click(screen.getByTestId('filters-clear')); - expect(clearFiltersMock).toHaveBeenCalled(); + expect(resetSpy).toHaveBeenCalled(); }); }); }); diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index 5ba2d6208..3d238bb7d 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -3,8 +3,6 @@ import type { FC } from 'react'; import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; import { Button, Stack, Tooltip } from '@primer/react'; -import { useAppContext } from '../hooks/useAppContext'; - import { ReasonFilter } from '../components/filters/ReasonFilter'; import { SearchFilter } from '../components/filters/SearchFilter'; import { StateFilter } from '../components/filters/StateFilter'; @@ -15,8 +13,10 @@ import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { useFiltersStore } from '../stores'; + export const FiltersRoute: FC = () => { - const { clearFilters } = useAppContext(); + const clearFilters = useFiltersStore((s) => s.reset); return ( diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts new file mode 100644 index 000000000..35af2bf4c --- /dev/null +++ b/src/renderer/stores/defaults.ts @@ -0,0 +1,13 @@ +import type { FiltersState } from './types'; + +/** + * Default filters state + */ +export const DEFAULT_FILTERS_STATE: FiltersState = { + includeSearchTokens: [], + excludeSearchTokens: [], + userTypes: [], + subjectTypes: [], + states: [], + reasons: [], +}; diff --git a/src/renderer/stores/index.ts b/src/renderer/stores/index.ts new file mode 100644 index 000000000..19b310426 --- /dev/null +++ b/src/renderer/stores/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export { default as useFiltersStore } from './useFiltersStore'; diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts new file mode 100644 index 000000000..af427e078 --- /dev/null +++ b/src/renderer/stores/types.ts @@ -0,0 +1,75 @@ +import type { + FilterStateType, + Reason, + SearchToken, + SubjectType, + UserType, +} from '../types'; + +// ============================================================================ +// Filters Store Types +// ============================================================================ + +/** + * Settings related to the filtering of notifications within the application. + */ +export interface FiltersState { + /** + * The search tokens to include notifications by. + */ + includeSearchTokens: SearchToken[]; + + /** + * The search tokens to exclude notifications by. + */ + excludeSearchTokens: SearchToken[]; + + /** + * The user types to filter notifications by. + */ + userTypes: UserType[]; + + /** + * The subject types to filter notifications by. + */ + subjectTypes: SubjectType[]; + + /** + * The states to filter notifications by. + */ + states: FilterStateType[]; + + /** + * The reasons to filter notifications by. + */ + reasons: Reason[]; +} + +/** + * All allowed Filter types. + * Automatically derived from the FiltersState. + */ +export type FilterKey = keyof FiltersState; + +/** + * Type-safe update function for filters. + */ +export type UpdateFilter = ( + key: K, + value: FiltersState[K][number], + checked: boolean, +) => void; + +/** + * Actions for managing filters. + */ +export interface FiltersActions { + hasActiveFilters: () => boolean; + updateFilter: UpdateFilter; + reset: () => void; +} + +/** + * Complete filters store type. + */ +export type FiltersStore = FiltersState & FiltersActions; diff --git a/src/renderer/stores/useFiltersStore.test.ts b/src/renderer/stores/useFiltersStore.test.ts new file mode 100644 index 000000000..3d76f680c --- /dev/null +++ b/src/renderer/stores/useFiltersStore.test.ts @@ -0,0 +1,92 @@ +import { act, renderHook } from '@testing-library/react'; + +import type { SearchToken } from '../types'; + +import { DEFAULT_FILTERS_STATE } from './defaults'; +import useFiltersStore from './useFiltersStore'; + +describe('useFiltersStore', () => { + test('should start with default filters', () => { + const { result } = renderHook(() => useFiltersStore()); + + expect(result.current).toMatchObject(DEFAULT_FILTERS_STATE); + }); + + test('should update a filter (add value)', () => { + const { result } = renderHook(() => useFiltersStore()); + + act(() => { + result.current.updateFilter('subjectTypes', 'Issue', true); + }); + + expect(result.current.subjectTypes).toContain('Issue'); + }); + + test('should update a filter (remove value)', () => { + const { result } = renderHook(() => useFiltersStore()); + + act(() => { + result.current.updateFilter('subjectTypes', 'Issue', true); + result.current.updateFilter('subjectTypes', 'Issue', false); + }); + + expect(result.current.subjectTypes).not.toContain('Issue'); + }); + + test('should reset filters to default', () => { + const { result } = renderHook(() => useFiltersStore()); + + act(() => { + result.current.updateFilter('subjectTypes', 'Issue', true); + result.current.reset(); + }); + + expect(result.current).toMatchObject(DEFAULT_FILTERS_STATE); + }); + + describe('hasActiveFilters', () => { + it('default filter settings', () => { + expect(useFiltersStore.getState().hasActiveFilters()).toBe(false); + }); + + it('non-default include search tokens filters', () => { + useFiltersStore.setState({ + includeSearchTokens: ['org:gitify-app' as SearchToken], + }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + + it('non-default exclude search tokens filters', () => { + useFiltersStore.setState({ + excludeSearchTokens: ['org:gitify-app' as SearchToken], + }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + + it('non-default user types filters', () => { + useFiltersStore.setState({ userTypes: ['Bot'] }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + + it('non-default subject types filters', () => { + useFiltersStore.setState({ subjectTypes: ['Issue'] }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + + it('non-default state filters', () => { + useFiltersStore.setState({ states: ['draft'] }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + + it('non-default reason filters', () => { + useFiltersStore.setState({ reasons: ['review_requested'] }); + + expect(useFiltersStore.getState().hasActiveFilters()).toBe(true); + }); + }); +}); diff --git a/src/renderer/stores/useFiltersStore.ts b/src/renderer/stores/useFiltersStore.ts new file mode 100644 index 000000000..78dbe6f7f --- /dev/null +++ b/src/renderer/stores/useFiltersStore.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { Constants } from '../constants'; + +import type { FiltersStore } from './types'; + +import { DEFAULT_FILTERS_STATE } from './defaults'; + +/** + * Atlassify Filters store. + * + * Automatically persisted to local storage + */ +const useFiltersStore = create()( + persist( + (set, get, store) => ({ + ...DEFAULT_FILTERS_STATE, + + hasActiveFilters: () => { + const state = get(); + return ( + state.includeSearchTokens.length > 0 || + state.excludeSearchTokens.length > 0 || + state.userTypes.length > 0 || + state.subjectTypes.length > 0 || + state.states.length > 0 || + state.reasons.length > 0 + ); + }, + + updateFilter: (key, value, checked) => { + set((state) => { + const current = state[key]; + + if (checked) { + return { + [key]: [...current, value], + }; + } + + return { [key]: current.filter((item) => item !== value) }; + }); + }, + + reset: () => { + set(store.getInitialState()); + }, + }), + { + name: Constants.FILTERS_STORE_KEY, + }, + ), +); + +export default useFiltersStore; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 41bcccd2c..1a7d4673b 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -51,15 +51,10 @@ export interface Account { hasRequiredScopes?: boolean; } -/** - * All allowed Config and Filter Settings values to be stored in the application. - */ -export type SettingsValue = ConfigSettingsValue | FilterSettingsValue[]; - /** * All Config Settings values to be stored in the application. */ -export type ConfigSettingsValue = +export type SettingsValue = | boolean | number | FetchType @@ -69,24 +64,9 @@ export type ConfigSettingsValue = | Theme; /** - * All Filter Settings values to be stored in the application. + * All allowed Config Settings keys to be stored in the application. */ -export type FilterSettingsValue = - | FilterStateType - | Reason - | SearchToken - | SubjectType - | UserType; - -/** - * All allowed Config and Filter Settings keys to be stored in the application. - */ -export type SettingsState = ConfigSettingsState & FilterSettingsState; - -/** - * All Config Settings keys to be stored in the application. - */ -export type ConfigSettingsState = AppearanceSettingsState & +export type SettingsState = AppearanceSettingsState & NotificationSettingsState & TraySettingsState & SystemSettingsState; @@ -141,18 +121,6 @@ export interface SystemSettingsState { openAtStartup: boolean; } -/** - * Settings related to the filtering of notifications within the application. - */ -export interface FilterSettingsState { - filterIncludeSearchTokens: SearchToken[]; - filterExcludeSearchTokens: SearchToken[]; - filterUserTypes: UserType[]; - filterSubjectTypes: SubjectType[]; - filterStates: FilterStateType[]; - filterReasons: Reason[]; -} - export interface AuthState { accounts: Account[]; } diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index dd0503609..8d4afb693 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -4239,6 +4239,13 @@ export type PullRequestBranchUpdateMethod = /** Update branch via rebase */ | 'REBASE'; +/** The policy controlling who can create pull requests in a repository. */ +export type PullRequestCreationPolicy = + /** Anyone can create pull requests. */ + | 'ALL' + /** Only collaborators can create pull requests. */ + | 'COLLABORATORS_ONLY'; + /** Represents available types of methods to use when merging a pull request. */ export type PullRequestMergeMethod = /** Add all commits from the head branch to the base branch with a merge commit. */ @@ -7506,6 +7513,8 @@ export type UpdateRepositoryInput = { hasIssuesEnabled?: InputMaybe; /** Indicates if the repository should have the project boards feature enabled. */ hasProjectsEnabled?: InputMaybe; + /** Indicates if the repository should have the pull requests feature enabled. */ + hasPullRequestsEnabled?: InputMaybe; /** Indicates if the repository displays a Sponsor button for financial contributions. */ hasSponsorshipsEnabled?: InputMaybe; /** Indicates if the repository should have the wiki feature enabled. */ @@ -7514,6 +7523,8 @@ export type UpdateRepositoryInput = { homepageUrl?: InputMaybe; /** The new name of the repository. */ name?: InputMaybe; + /** The policy controlling who can create pull requests in this repository. */ + pullRequestCreationPolicy?: InputMaybe; /** The ID of the repository to update. */ repositoryId: Scalars['ID']['input']; /** Whether this repository should be marked as a template such that anyone who can access it can create new repositories with the same files and directory structure. */ diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 43d7469d4..8c3df364a 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -70,6 +70,11 @@ describe('renderer/utils/auth/utils.ts', () => { }); describe('performGitHubDeviceOAuth', () => { + beforeEach(() => { + // Mock OAUTH_DEVICE_FLOW_CLIENT_ID value + Constants.OAUTH_DEVICE_FLOW_CLIENT_ID = 'FAKE_CLIENT_ID_123' as ClientID; + }); + it('should authenticate using device flow for GitHub app', async () => { createDeviceCodeMock.mockResolvedValueOnce({ data: { diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index c0a120efe..7eb19bbee 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -1,20 +1,10 @@ import { mockPartialGitifyNotification } from '../../../__mocks__/notifications-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; -import { defaultSettings } from '../../../context/defaults'; +import type { GitifyOwner, Link, SearchToken } from '../../../types'; -import type { - GitifyOwner, - Link, - SearchToken, - SettingsState, -} from '../../../types'; - -import { - filterBaseNotifications, - filterDetailedNotifications, - hasActiveFilters, -} from './filter'; +import { useFiltersStore } from '../../../stores'; +import { filterBaseNotifications, filterDetailedNotifications } from './filter'; describe('renderer/utils/notifications/filters/filter.ts', () => { afterEach(() => { @@ -69,65 +59,71 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { describe('filterBaseNotifications', () => { it('should filter notifications by subject type when provided', () => { + useFiltersStore.setState({ + subjectTypes: ['Issue'], + }); + mockNotifications[0].subject.type = 'Issue'; mockNotifications[1].subject.type = 'PullRequest'; - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterSubjectTypes: ['Issue'], - }); + const result = filterBaseNotifications(mockNotifications); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); it('should filter notifications by reasons when provided', async () => { + useFiltersStore.setState({ + reasons: ['manual'], + }); + mockNotifications[0].reason.code = 'subscribed'; mockNotifications[1].reason.code = 'manual'; - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterReasons: ['manual'], - }); + const result = filterBaseNotifications(mockNotifications); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); it('should filter notifications that match include organization', () => { - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], + useFiltersStore.setState({ + includeSearchTokens: ['org:gitify-app' as SearchToken], }); + const result = filterBaseNotifications(mockNotifications); + expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); it('should filter notifications that match exclude organization', () => { - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterExcludeSearchTokens: ['org:github' as SearchToken], + useFiltersStore.setState({ + excludeSearchTokens: ['org:github' as SearchToken], }); + const result = filterBaseNotifications(mockNotifications); + expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); it('should filter notifications that match include repository', () => { - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], + useFiltersStore.setState({ + includeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], }); + const result = filterBaseNotifications(mockNotifications); + expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); it('should filter notifications that match exclude repository', () => { - const result = filterBaseNotifications(mockNotifications, { - ...mockSettings, - filterExcludeSearchTokens: ['repo:github/github' as SearchToken], + useFiltersStore.setState({ + excludeSearchTokens: ['repo:github/github' as SearchToken], }); + const result = filterBaseNotifications(mockNotifications); + expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[0]]); }); @@ -135,13 +131,16 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { describe('filterDetailedNotifications', () => { it('should ignore user type, handle filters and state filters if detailed notifications not enabled', async () => { + useFiltersStore.setState({ + userTypes: ['Bot'], + includeSearchTokens: ['author:github-user' as SearchToken], + excludeSearchTokens: ['author:github-bot' as SearchToken], + states: ['merged'], + }); + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: false, - filterUserTypes: ['Bot'], - filterIncludeSearchTokens: ['author:github-user' as SearchToken], - filterExcludeSearchTokens: ['author:github-bot' as SearchToken], - filterStates: ['merged'], }); expect(result.length).toBe(2); @@ -149,10 +148,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications by user type provided', async () => { + useFiltersStore.setState({ + userTypes: ['Bot'], + }); + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterUserTypes: ['Bot'], }); expect(result.length).toBe(1); @@ -160,10 +162,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications that match include author handle', async () => { + useFiltersStore.setState({ + includeSearchTokens: ['author:github-user' as SearchToken], + }); + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterIncludeSearchTokens: ['author:github-user' as SearchToken], }); expect(result.length).toBe(1); @@ -171,10 +176,13 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications that match exclude author handle', async () => { + useFiltersStore.setState({ + excludeSearchTokens: ['author:github-bot' as SearchToken], + }); + const result = filterDetailedNotifications(mockNotifications, { ...mockSettings, detailedNotifications: true, - filterExcludeSearchTokens: ['author:github-bot' as SearchToken], }); expect(result.length).toBe(1); @@ -182,70 +190,18 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { }); it('should filter notifications by state when provided', async () => { + useFiltersStore.setState({ states: ['closed'] }); + mockNotifications[0].subject.state = 'OPEN'; mockNotifications[1].subject.state = 'CLOSED'; - const result = filterDetailedNotifications(mockNotifications, { - ...mockSettings, - filterStates: ['closed'], - }); + const result = filterDetailedNotifications( + mockNotifications, + mockSettings, + ); expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); }); }); - - describe('hasActiveFilters', () => { - it('default filter settings', () => { - expect(hasActiveFilters(defaultSettings)).toBe(false); - }); - - it('non-default search token includes filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterIncludeSearchTokens: ['author:gitify' as SearchToken], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - - it('non-default search token excludes filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterExcludeSearchTokens: ['org:github' as SearchToken], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - - it('non-default user type filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterUserTypes: ['Bot'], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - - it('non-default subject type filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterSubjectTypes: ['Issue'], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - - it('non-default state filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterStates: ['draft', 'merged'], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - - it('non-default reason filters', () => { - const settings: SettingsState = { - ...defaultSettings, - filterReasons: ['subscribed', 'manual'], - }; - expect(hasActiveFilters(settings)).toBe(true); - }); - }); }); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index a8965611d..45ae1a1c3 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -5,6 +5,7 @@ import type { SettingsState, } from '../../../types'; +import useFiltersStore from '../../../stores/useFiltersStore'; import { BASE_SEARCH_QUALIFIERS, DETAILED_ONLY_SEARCH_QUALIFIERS, @@ -20,9 +21,10 @@ import { export function filterBaseNotifications( notifications: GitifyNotification[], - settings: SettingsState, ): GitifyNotification[] { return notifications.filter((notification) => { + const filters = useFiltersStore.getState(); + let passesFilters = true; // Apply base qualifier include/exclude filters (org, repo, etc.) @@ -33,21 +35,21 @@ export function filterBaseNotifications( passesFilters = passesFilters && - passesSearchTokenFiltersForQualifier(notification, settings, qualifier); + passesSearchTokenFiltersForQualifier(notification, qualifier); } - if (subjectTypeFilter.hasFilters(settings)) { + if (subjectTypeFilter.hasFilters()) { passesFilters = passesFilters && - settings.filterSubjectTypes.some((subjectType) => + filters.subjectTypes.some((subjectType) => subjectTypeFilter.filterNotification(notification, subjectType), ); } - if (reasonFilter.hasFilters(settings)) { + if (reasonFilter.hasFilters()) { passesFilters = passesFilters && - settings.filterReasons.some((reason) => + filters.reasons.some((reason) => reasonFilter.filterNotification(notification, reason), ); } @@ -64,41 +66,29 @@ export function filterDetailedNotifications( let passesFilters = true; if (settings.detailedNotifications) { - passesFilters = - passesFilters && passesUserFilters(notification, settings); + passesFilters = passesFilters && passesUserFilters(notification); - passesFilters = - passesFilters && passesStateFilter(notification, settings); + passesFilters = passesFilters && passesStateFilter(notification); } return passesFilters; }); } -export function hasActiveFilters(settings: SettingsState): boolean { - return ( - userTypeFilter.hasFilters(settings) || - hasIncludeSearchFilters(settings) || - hasExcludeSearchFilters(settings) || - subjectTypeFilter.hasFilters(settings) || - stateFilter.hasFilters(settings) || - reasonFilter.hasFilters(settings) - ); -} - /** * Apply include/exclude search token logic for a specific search qualifier prefix. */ function passesSearchTokenFiltersForQualifier( notification: GitifyNotification, - settings: SettingsState, qualifier: SearchQualifier, ): boolean { + const filters = useFiltersStore.getState(); + let passes = true; const prefix = qualifier.prefix; - if (hasIncludeSearchFilters(settings)) { - const includeTokens = settings.filterIncludeSearchTokens.filter((t) => + if (hasIncludeSearchFilters()) { + const includeTokens = filters.includeSearchTokens.filter((t) => t.startsWith(prefix), ); if (includeTokens.length > 0) { @@ -110,8 +100,8 @@ function passesSearchTokenFiltersForQualifier( } } - if (hasExcludeSearchFilters(settings)) { - const excludeTokens = settings.filterExcludeSearchTokens.filter((t) => + if (hasExcludeSearchFilters()) { + const excludeTokens = filters.excludeSearchTokens.filter((t) => t.startsWith(prefix), ); if (excludeTokens.length > 0) { @@ -126,16 +116,15 @@ function passesSearchTokenFiltersForQualifier( return passes; } -function passesUserFilters( - notification: GitifyNotification, - settings: SettingsState, -): boolean { +function passesUserFilters(notification: GitifyNotification): boolean { + const filters = useFiltersStore.getState(); + let passesFilters = true; - if (userTypeFilter.hasFilters(settings)) { + if (userTypeFilter.hasFilters()) { passesFilters = passesFilters && - settings.filterUserTypes.some((userType) => + filters.userTypes.some((userType) => userTypeFilter.filterNotification(notification, userType), ); } @@ -148,18 +137,17 @@ function passesUserFilters( passesFilters = passesFilters && - passesSearchTokenFiltersForQualifier(notification, settings, qualifier); + passesSearchTokenFiltersForQualifier(notification, qualifier); } return passesFilters; } -function passesStateFilter( - notification: GitifyNotification, - settings: SettingsState, -): boolean { - if (stateFilter.hasFilters(settings)) { - return settings.filterStates.some((state) => +function passesStateFilter(notification: GitifyNotification): boolean { + const filters = useFiltersStore.getState(); + + if (stateFilter.hasFilters()) { + return filters.states.some((state) => stateFilter.filterNotification(notification, state), ); } @@ -167,20 +155,14 @@ function passesStateFilter( return true; } -export function isStateFilteredOut( - state: GitifyNotificationState, - settings: SettingsState, -): boolean { +export function isStateFilteredOut(state: GitifyNotificationState): boolean { const notification = { subject: { state: state } } as GitifyNotification; - return !passesStateFilter(notification, settings); + return !passesStateFilter(notification); } -export function isUserFilteredOut( - user: GitifyNotificationUser, - settings: SettingsState, -): boolean { +export function isUserFilteredOut(user: GitifyNotificationUser): boolean { const notification = { subject: { user: user } } as GitifyNotification; - return !passesUserFilters(notification, settings); + return !passesUserFilters(notification); } diff --git a/src/renderer/utils/notifications/filters/reason.ts b/src/renderer/utils/notifications/filters/reason.ts index 510a798b5..6b8ab96df 100644 --- a/src/renderer/utils/notifications/filters/reason.ts +++ b/src/renderer/utils/notifications/filters/reason.ts @@ -2,11 +2,11 @@ import type { AccountNotifications, GitifyNotification, Reason, - SettingsState, TypeDetails, } from '../../../types'; import type { Filter } from './types'; +import { useFiltersStore } from '../../../stores'; import { REASON_TYPE_DETAILS } from '../../reason'; export const reasonFilter: Filter = { @@ -18,12 +18,14 @@ export const reasonFilter: Filter = { return this.FILTER_TYPES[reason]; }, - hasFilters(settings: SettingsState): boolean { - return settings.filterReasons.length > 0; + hasFilters(): boolean { + const filters = useFiltersStore.getState(); + return filters.reasons.length > 0; }, - isFilterSet(settings: SettingsState, reason: Reason): boolean { - return settings.filterReasons.includes(reason); + isFilterSet(reason: Reason): boolean { + const filters = useFiltersStore.getState(); + return filters.reasons.includes(reason); }, getFilterCount( diff --git a/src/renderer/utils/notifications/filters/search.ts b/src/renderer/utils/notifications/filters/search.ts index fba34c7e1..a5eed7576 100644 --- a/src/renderer/utils/notifications/filters/search.ts +++ b/src/renderer/utils/notifications/filters/search.ts @@ -1,4 +1,6 @@ -import type { GitifyNotification, SettingsState } from '../../../types'; +import type { GitifyNotification } from '../../../types'; + +import { useFiltersStore } from '../../../stores'; export const SEARCH_DELIMITER = ':'; @@ -37,12 +39,14 @@ export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] = ALL_SEARCH_QUALIFIERS.filter((q) => q.requiresDetailsNotifications); -export function hasIncludeSearchFilters(settings: SettingsState) { - return settings.filterIncludeSearchTokens.length > 0; +export function hasIncludeSearchFilters() { + const filters = useFiltersStore.getState(); + return filters.includeSearchTokens.length > 0; } -export function hasExcludeSearchFilters(settings: SettingsState) { - return settings.filterExcludeSearchTokens.length > 0; +export function hasExcludeSearchFilters() { + const filters = useFiltersStore.getState(); + return filters.excludeSearchTokens.length > 0; } export interface ParsedSearchToken { diff --git a/src/renderer/utils/notifications/filters/state.ts b/src/renderer/utils/notifications/filters/state.ts index 027efdcd9..f11e316fc 100644 --- a/src/renderer/utils/notifications/filters/state.ts +++ b/src/renderer/utils/notifications/filters/state.ts @@ -3,11 +3,12 @@ import type { FilterStateType, GitifyNotification, GitifyNotificationState, - SettingsState, TypeDetails, } from '../../../types'; import type { Filter } from './types'; +import { useFiltersStore } from '../../../stores'; + const STATE_TYPE_DETAILS: Record = { draft: { title: 'Draft', @@ -38,12 +39,14 @@ export const stateFilter: Filter = { return this.FILTER_TYPES[stateType]; }, - hasFilters(settings: SettingsState): boolean { - return settings.filterStates.length > 0; + hasFilters(): boolean { + const filters = useFiltersStore.getState(); + return filters.states.length > 0; }, - isFilterSet(settings: SettingsState, stateType: FilterStateType): boolean { - return settings.filterStates.includes(stateType); + isFilterSet(stateType: FilterStateType): boolean { + const filters = useFiltersStore.getState(); + return filters.states.includes(stateType); }, getFilterCount( diff --git a/src/renderer/utils/notifications/filters/subjectType.ts b/src/renderer/utils/notifications/filters/subjectType.ts index 156c1d666..3924abd06 100644 --- a/src/renderer/utils/notifications/filters/subjectType.ts +++ b/src/renderer/utils/notifications/filters/subjectType.ts @@ -1,12 +1,13 @@ import type { AccountNotifications, GitifyNotification, - SettingsState, SubjectType, TypeDetails, } from '../../../types'; import type { Filter } from './types'; +import useFiltersStore from '../../../stores/useFiltersStore'; + const SUBJECT_TYPE_DETAILS: Record = { CheckSuite: { title: 'Check Suite', @@ -49,12 +50,14 @@ export const subjectTypeFilter: Filter = { return this.FILTER_TYPES[subjectType]; }, - hasFilters(settings: SettingsState): boolean { - return settings.filterSubjectTypes.length > 0; + hasFilters(): boolean { + const filters = useFiltersStore.getState(); + return filters.subjectTypes.length > 0; }, - isFilterSet(settings: SettingsState, subjectType: SubjectType): boolean { - return settings.filterSubjectTypes.includes(subjectType); + isFilterSet(subjectType: SubjectType): boolean { + const filters = useFiltersStore.getState(); + return filters.subjectTypes.includes(subjectType); }, getFilterCount( diff --git a/src/renderer/utils/notifications/filters/types.ts b/src/renderer/utils/notifications/filters/types.ts index a0e6cdb54..9a3c57eb9 100644 --- a/src/renderer/utils/notifications/filters/types.ts +++ b/src/renderer/utils/notifications/filters/types.ts @@ -1,22 +1,44 @@ import type { AccountNotifications, GitifyNotification, - SettingsState, TypeDetails, } from '../../../types'; export interface Filter { FILTER_TYPES: Record; + /** + * Indicates whether this filter requires detailed notifications to function correctly. + */ requiresDetailsNotifications: boolean; getTypeDetails(type: T): TypeDetails; - hasFilters(settings: SettingsState): boolean; + /** + * Check if any filters have been set. + */ + hasFilters(): boolean; - isFilterSet(settings: SettingsState, type: T): boolean; + /** + * Check if a specific filter is set. + * + * @param type filter value to check against + */ + isFilterSet(type: T): boolean; + /** + * Return the count of notifications for a given filter type. + * + * @param accountNotifications Notifications + * @param type Filter type to count + */ getFilterCount(accountNotifications: AccountNotifications[], type: T): number; + /** + * Perform notification filtering. + * + * @param notification Notifications + * @param type filter value to use + */ filterNotification(notification: GitifyNotification, type: T): boolean; } diff --git a/src/renderer/utils/notifications/filters/userType.ts b/src/renderer/utils/notifications/filters/userType.ts index 93bcbb935..6568b66c4 100644 --- a/src/renderer/utils/notifications/filters/userType.ts +++ b/src/renderer/utils/notifications/filters/userType.ts @@ -1,12 +1,13 @@ import type { AccountNotifications, GitifyNotification, - SettingsState, TypeDetails, UserType, } from '../../../types'; import type { Filter } from './types'; +import { useFiltersStore } from '../../../stores'; + type FilterableUserType = Extract; const USER_TYPE_DETAILS: Record = { @@ -35,12 +36,14 @@ export const userTypeFilter: Filter = { return this.FILTER_TYPES[userType]; }, - hasFilters(settings: SettingsState): boolean { - return settings.filterUserTypes.length > 0; + hasFilters(): boolean { + const filters = useFiltersStore.getState(); + return filters.userTypes.length > 0; }, - isFilterSet(settings: SettingsState, userType: UserType): boolean { - return settings.filterUserTypes.includes(userType); + isFilterSet(userType: UserType): boolean { + const filters = useFiltersStore.getState(); + return filters.userTypes.includes(userType); }, getFilterCount( diff --git a/src/renderer/utils/notifications/handlers/commit.test.ts b/src/renderer/utils/notifications/handlers/commit.test.ts index 3bd46996f..46de12cd5 100644 --- a/src/renderer/utils/notifications/handlers/commit.test.ts +++ b/src/renderer/utils/notifications/handlers/commit.test.ts @@ -8,6 +8,7 @@ import type { GetCommitResponse, } from '../../api/types'; +import { useFiltersStore } from '../../../stores'; import * as apiClient from '../../api/client'; import { commitHandler } from './commit'; @@ -79,6 +80,8 @@ describe('renderer/utils/notifications/handlers/commit.ts', () => { }); it('return early if commit state filtered', async () => { + useFiltersStore.setState({ states: ['closed'] }); + const mockNotification = mockPartialGitifyNotification({ title: 'This is a commit with comments', type: 'Commit', @@ -86,10 +89,7 @@ describe('renderer/utils/notifications/handlers/commit.ts', () => { latestCommentUrl: null, }); - const result = await commitHandler.enrich(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); + const result = await commitHandler.enrich(mockNotification, mockSettings); // Returns empty object when filtered (no API call made) expect(result).toEqual({}); diff --git a/src/renderer/utils/notifications/handlers/commit.ts b/src/renderer/utils/notifications/handlers/commit.ts index f2fde21ea..5ea7c2429 100644 --- a/src/renderer/utils/notifications/handlers/commit.ts +++ b/src/renderer/utils/notifications/handlers/commit.ts @@ -22,12 +22,12 @@ class CommitHandler extends DefaultHandler { async enrich( notification: GitifyNotification, - settings: SettingsState, + _settings: SettingsState, ): Promise> { const commitState: GitifyNotificationState = null; // Commit notifications are stateless // Return early if this notification would be hidden by filters - if (isStateFilteredOut(commitState, settings)) { + if (isStateFilteredOut(commitState)) { return {}; } diff --git a/src/renderer/utils/notifications/handlers/release.test.ts b/src/renderer/utils/notifications/handlers/release.test.ts index ab464bf05..13bccca96 100644 --- a/src/renderer/utils/notifications/handlers/release.test.ts +++ b/src/renderer/utils/notifications/handlers/release.test.ts @@ -5,6 +5,7 @@ import { mockRawUser } from '../../api/__mocks__/response-mocks'; import type { GitifyNotification, Link } from '../../../types'; import type { GetReleaseResponse } from '../../api/types'; +import { useFiltersStore } from '../../../stores'; import * as apiClient from '../../api/client'; import { releaseHandler } from './release'; @@ -44,10 +45,12 @@ describe('renderer/utils/notifications/handlers/release.ts', () => { }); it('return early if release state filtered', async () => { - const result = await releaseHandler.enrich(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); + useFiltersStore.setState({ states: ['closed'] }); + + const result = await releaseHandler.enrich( + mockNotification, + mockSettings, + ); // Returns empty object when filtered (no API call made) expect(result).toEqual({}); diff --git a/src/renderer/utils/notifications/handlers/release.ts b/src/renderer/utils/notifications/handlers/release.ts index 343a5672a..1d2678926 100644 --- a/src/renderer/utils/notifications/handlers/release.ts +++ b/src/renderer/utils/notifications/handlers/release.ts @@ -23,12 +23,12 @@ class ReleaseHandler extends DefaultHandler { async enrich( notification: GitifyNotification, - settings: SettingsState, + _settings: SettingsState, ): Promise> { const releaseState: GitifyNotificationState = null; // Release notifications are stateless // Return early if this notification would be hidden by filters - if (isStateFilteredOut(releaseState, settings)) { + if (isStateFilteredOut(releaseState)) { return {}; } diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index 29a63d075..973200244 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -97,10 +97,7 @@ export async function getAllNotifications( accountNotifications.account, ); - notifications = filterBaseNotifications( - notifications, - state.settings, - ); + notifications = filterBaseNotifications(notifications); notifications = await enrichNotifications( notifications,