Skip to content

Commit d33954e

Browse files
authored
refactor(filters): use zustand for filters store (#2633)
* refactor(filters): use zustand for filters store Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(filters): use zustand for filters store Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(filters): use zustand for filters store Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(filters): use zustand for filters store Signed-off-by: Adam Setch <adam.setch@outlook.com> --------- Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent a68b997 commit d33954e

44 files changed

Lines changed: 601 additions & 962 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@
124124
"vite-plugin-electron": "0.29.0",
125125
"vite-plugin-electron-renderer": "0.14.6",
126126
"vite-plugin-static-copy": "3.2.0",
127-
"vitest": "4.0.18"
127+
"vitest": "4.0.18",
128+
"zustand": "5.0.11"
128129
},
129130
"packageManager": "pnpm@10.30.0",
130131
"pnpm": {

pnpm-lock.yaml

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/renderer/__helpers__/vitest.setup.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import '@testing-library/jest-dom/vitest';
22

3+
import { useFiltersStore } from '../stores';
4+
35
// Sets timezone to UTC for consistent date/time in tests and snapshots
46
process.env.TZ = 'UTC';
57

6-
// Mock OAuth client ID and secret
7-
process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123';
8+
/**
9+
* Reset stores
10+
*/
11+
beforeEach(() => {
12+
useFiltersStore.getState().reset();
13+
});
814

915
/**
1016
* Gitify context bridge API

src/renderer/__mocks__/state-mocks.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
type AppearanceSettingsState,
55
type AuthState,
66
FetchType,
7-
type FilterSettingsState,
87
type GitifyState,
98
GroupBy,
109
type NotificationSettingsState,
@@ -66,21 +65,11 @@ const mockSystemSettings: SystemSettingsState = {
6665
openAtStartup: false,
6766
};
6867

69-
const mockFilters: FilterSettingsState = {
70-
filterUserTypes: [],
71-
filterIncludeSearchTokens: [],
72-
filterExcludeSearchTokens: [],
73-
filterSubjectTypes: [],
74-
filterStates: [],
75-
filterReasons: [],
76-
};
77-
7868
export const mockSettings: SettingsState = {
7969
...mockAppearanceSettings,
8070
...mockNotificationSettings,
8171
...mockTraySettings,
8272
...mockSystemSettings,
83-
...mockFilters,
8473
};
8574

8675
export const mockState: GitifyState = {

src/renderer/components/AllRead.test.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '../__helpers__/test-utils';
77
import { mockSettings } from '../__mocks__/state-mocks';
88

9+
import { useFiltersStore } from '../stores';
910
import { AllRead } from './AllRead';
1011

1112
describe('renderer/components/AllRead.tsx', () => {
@@ -20,12 +21,6 @@ describe('renderer/components/AllRead.tsx', () => {
2021
tree = renderWithAppContext(<AllRead />, {
2122
settings: {
2223
...mockSettings,
23-
filterReasons: [],
24-
filterStates: [],
25-
filterSubjectTypes: [],
26-
filterUserTypes: [],
27-
filterIncludeSearchTokens: [],
28-
filterExcludeSearchTokens: [],
2924
},
3025
});
3126
});
@@ -34,13 +29,14 @@ describe('renderer/components/AllRead.tsx', () => {
3429
});
3530

3631
it('should render itself & its children - with filters', async () => {
32+
useFiltersStore.setState({ reasons: ['author'] });
33+
3734
let tree: ReturnType<typeof renderWithAppContext> | null = null;
3835

3936
await act(async () => {
4037
tree = renderWithAppContext(<AllRead />, {
4138
settings: {
4239
...mockSettings,
43-
filterReasons: ['author'],
4440
},
4541
});
4642
});

src/renderer/components/AllRead.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import { type FC, useMemo } from 'react';
22

33
import { Constants } from '../constants';
44

5-
import { useAppContext } from '../hooks/useAppContext';
6-
75
import { EmojiSplash } from './layout/EmojiSplash';
86

9-
import { hasActiveFilters } from '../utils/notifications/filters/filter';
7+
import { useFiltersStore } from '../stores';
108

119
interface AllReadProps {
1210
fullHeight?: boolean;
@@ -15,9 +13,7 @@ interface AllReadProps {
1513
export const AllRead: FC<AllReadProps> = ({
1614
fullHeight = true,
1715
}: AllReadProps) => {
18-
const { settings } = useAppContext();
19-
20-
const hasFilters = hasActiveFilters(settings);
16+
const hasFilters = useFiltersStore((s) => s.hasActiveFilters());
2117

2218
const emoji = useMemo(
2319
() =>

src/renderer/components/Sidebar.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { renderWithAppContext } from '../__helpers__/test-utils';
66
import { mockMultipleAccountNotifications } from '../__mocks__/notifications-mocks';
77
import { mockSettings } from '../__mocks__/state-mocks';
88

9+
import { useFiltersStore } from '../stores';
910
import * as comms from '../utils/comms';
1011
import { Sidebar } from './Sidebar';
1112

@@ -185,15 +186,14 @@ describe('renderer/components/Sidebar.tsx', () => {
185186
});
186187

187188
it('highlight filters sidebar if any are saved', () => {
189+
useFiltersStore.setState({ reasons: ['assign'] });
190+
188191
renderWithAppContext(
189192
<MemoryRouter>
190193
<Sidebar />
191194
</MemoryRouter>,
192195
{
193-
settings: {
194-
...mockSettings,
195-
filterReasons: ['assign'],
196-
},
196+
settings: mockSettings,
197197
},
198198
);
199199

src/renderer/components/Sidebar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { APPLICATION } from '../../shared/constants';
1818
import { useAppContext } from '../hooks/useAppContext';
1919
import { useShortcutActions } from '../hooks/useShortcutActions';
2020

21-
import { hasActiveFilters } from '../utils/notifications/filters/filter';
21+
import { useFiltersStore } from '../stores';
2222
import { LogoIcon } from './icons/LogoIcon';
2323

2424
export const Sidebar: FC = () => {
@@ -32,6 +32,8 @@ export const Sidebar: FC = () => {
3232

3333
const { shortcuts } = useShortcutActions();
3434

35+
const hasFilters = useFiltersStore((s) => s.hasActiveFilters());
36+
3537
const isLoading = status === 'loading';
3638

3739
return (
@@ -97,7 +99,7 @@ export const Sidebar: FC = () => {
9799
onClick={() => shortcuts.filters.action()}
98100
size="small"
99101
tooltipDirection="e"
100-
variant={hasActiveFilters(settings) ? 'primary' : 'invisible'}
102+
variant={hasFilters ? 'primary' : 'invisible'}
101103
/>
102104
</>
103105
)}

src/renderer/components/filters/FilterSection.test.tsx

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@ import { renderWithAppContext } from '../../__helpers__/test-utils';
77
import { mockMultipleAccountNotifications } from '../../__mocks__/notifications-mocks';
88
import { mockSettings } from '../../__mocks__/state-mocks';
99

10+
import { useFiltersStore } from '../../stores';
1011
import { stateFilter } from '../../utils/notifications/filters';
1112
import { FilterSection } from './FilterSection';
1213

1314
describe('renderer/components/filters/FilterSection.tsx', () => {
14-
const updateFilterMock = vi.fn();
15-
1615
const mockFilter = stateFilter;
17-
const mockFilterSetting = 'filterStates';
16+
const mockFilterSetting = 'states';
17+
18+
let updateFilterSpy: ReturnType<typeof vi.spyOn>;
19+
20+
beforeEach(() => {
21+
updateFilterSpy = vi.spyOn(useFiltersStore.getState(), 'updateFilter');
22+
});
23+
24+
afterEach(() => {
25+
vi.clearAllMocks();
26+
});
1827

1928
describe('should render itself & its children', () => {
2029
it('with detailed notifications enabled', () => {
@@ -77,29 +86,25 @@ describe('renderer/components/filters/FilterSection.tsx', () => {
7786
title={'FilterSectionTitle'}
7887
/>,
7988
{
80-
settings: {
81-
...mockSettings,
82-
filterStates: [],
83-
},
84-
updateFilter: updateFilterMock,
89+
settings: mockSettings,
8590
},
8691
);
8792
});
8893

8994
await userEvent.click(screen.getByLabelText('Open'));
9095

91-
expect(updateFilterMock).toHaveBeenCalledWith(
96+
expect(updateFilterSpy).toHaveBeenCalledWith(
9297
mockFilterSetting,
9398
'open',
9499
true,
95100
);
96-
97-
expect(
98-
screen.getByLabelText('Open').parentNode.parentNode,
99-
).toMatchSnapshot();
100101
});
101102

102103
it('should be able to toggle filter value - some filters already set', async () => {
104+
useFiltersStore.setState({
105+
states: ['open'],
106+
});
107+
103108
await act(async () => {
104109
renderWithAppContext(
105110
<FilterSection
@@ -110,25 +115,17 @@ describe('renderer/components/filters/FilterSection.tsx', () => {
110115
title={'FilterSectionTitle'}
111116
/>,
112117
{
113-
settings: {
114-
...mockSettings,
115-
filterStates: ['open'],
116-
},
117-
updateFilter: updateFilterMock,
118+
settings: mockSettings,
118119
},
119120
);
120121
});
121122

122123
await userEvent.click(screen.getByLabelText('Closed'));
123124

124-
expect(updateFilterMock).toHaveBeenCalledWith(
125+
expect(updateFilterSpy).toHaveBeenCalledWith(
125126
mockFilterSetting,
126127
'closed',
127128
true,
128129
);
129-
130-
expect(
131-
screen.getByLabelText('Closed').parentNode.parentNode,
132-
).toMatchSnapshot();
133130
});
134131
});

src/renderer/components/filters/FilterSection.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactNode } from 'react';
1+
import { memo, type ReactNode, useMemo } from 'react';
22

33
import type { Icon } from '@primer/octicons-react';
44
import { Stack, Text } from '@primer/react';
@@ -8,31 +8,45 @@ import { useAppContext } from '../../hooks/useAppContext';
88
import { Checkbox } from '../fields/Checkbox';
99
import { Title } from '../primitives/Title';
1010

11-
import type { FilterSettingsState, FilterSettingsValue } from '../../types';
12-
11+
import { type FiltersState, useFiltersStore } from '../../stores';
1312
import type { Filter } from '../../utils/notifications/filters';
1413
import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
1514

16-
export interface FilterSectionProps<T extends FilterSettingsValue> {
15+
export interface FilterSectionProps<K extends keyof FiltersState> {
1716
id: string;
1817
title: string;
1918
icon: Icon;
20-
filter: Filter<T>;
21-
filterSetting: keyof FilterSettingsState;
19+
filter: Filter<FiltersState[K][number]>;
20+
filterSetting: K;
2221
tooltip?: ReactNode;
2322
layout?: 'horizontal' | 'vertical';
2423
}
2524

26-
export const FilterSection = <T extends FilterSettingsValue>({
25+
const FilterSectionComponent = <K extends keyof FiltersState>({
2726
id,
2827
title,
2928
icon,
3029
filter,
3130
filterSetting,
3231
tooltip,
3332
layout = 'vertical',
34-
}: FilterSectionProps<T>) => {
35-
const { updateFilter, settings, notifications } = useAppContext();
33+
}: FilterSectionProps<K>) => {
34+
const { notifications, settings } = useAppContext();
35+
const updateFilter = useFiltersStore((s) => s.updateFilter);
36+
37+
// Subscribe to the specific filter state so component re-renders when filters change
38+
useFiltersStore((s) => s[filterSetting]);
39+
40+
// Memoize filter counts to avoid recalculating on every render
41+
const filterCounts = useMemo(() => {
42+
const counts = new Map<FiltersState[K][number], number>();
43+
for (const type of Object.keys(
44+
filter.FILTER_TYPES,
45+
) as FiltersState[K][number][]) {
46+
counts.set(type, filter.getFilterCount(notifications, type));
47+
}
48+
return counts;
49+
}, [notifications, filter]);
3650

3751
return (
3852
<fieldset id={id}>
@@ -56,7 +70,7 @@ export const FilterSection = <T extends FilterSettingsValue>({
5670
direction={layout}
5771
gap={layout === 'horizontal' ? 'normal' : 'condensed'}
5872
>
59-
{(Object.keys(filter.FILTER_TYPES) as T[])
73+
{(Object.keys(filter.FILTER_TYPES) as FiltersState[K][number][])
6074
.sort((a, b) =>
6175
filter
6276
.getTypeDetails(a)
@@ -69,8 +83,8 @@ export const FilterSection = <T extends FilterSettingsValue>({
6983
const typeDetails = filter.getTypeDetails(type);
7084
const typeTitle = typeDetails.title;
7185
const typeDescription = typeDetails.description;
72-
const isChecked = filter.isFilterSet(settings, type);
73-
const count = filter.getFilterCount(notifications, type);
86+
const isChecked = filter.isFilterSet(type);
87+
const count = filterCounts.get(type) ?? 0;
7488

7589
return (
7690
<Checkbox
@@ -94,3 +108,8 @@ export const FilterSection = <T extends FilterSettingsValue>({
94108
</fieldset>
95109
);
96110
};
111+
112+
// Memoize the component to prevent unnecessary re-renders
113+
export const FilterSection = memo(
114+
FilterSectionComponent,
115+
) as typeof FilterSectionComponent;

0 commit comments

Comments
 (0)