Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 0 additions & 11 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
type AppearanceSettingsState,
type AuthState,
FetchType,
type FilterSettingsState,
type GitifyState,
GroupBy,
type NotificationSettingsState,
Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 3 additions & 7 deletions src/renderer/components/AllRead.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -20,12 +21,6 @@ describe('renderer/components/AllRead.tsx', () => {
tree = renderWithAppContext(<AllRead />, {
settings: {
...mockSettings,
filterReasons: [],
filterStates: [],
filterSubjectTypes: [],
filterUserTypes: [],
filterIncludeSearchTokens: [],
filterExcludeSearchTokens: [],
},
});
});
Expand All @@ -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<typeof renderWithAppContext> | null = null;

await act(async () => {
tree = renderWithAppContext(<AllRead />, {
settings: {
...mockSettings,
filterReasons: ['author'],
},
});
});
Expand Down
8 changes: 2 additions & 6 deletions src/renderer/components/AllRead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,9 +13,7 @@ interface AllReadProps {
export const AllRead: FC<AllReadProps> = ({
fullHeight = true,
}: AllReadProps) => {
const { settings } = useAppContext();

const hasFilters = hasActiveFilters(settings);
const hasFilters = useFiltersStore((s) => s.hasActiveFilters());

const emoji = useMemo(
() =>
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

it('highlight filters sidebar if any are saved', () => {
useFiltersStore.setState({ reasons: ['assign'] });

renderWithAppContext(
<MemoryRouter>
<Sidebar />
</MemoryRouter>,
{
settings: {
...mockSettings,
filterReasons: ['assign'],
},
settings: mockSettings,
},
);

Expand Down
6 changes: 4 additions & 2 deletions src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -32,6 +32,8 @@ export const Sidebar: FC = () => {

const { shortcuts } = useShortcutActions();

const hasFilters = useFiltersStore((s) => s.hasActiveFilters());

const isLoading = status === 'loading';

return (
Expand Down Expand Up @@ -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'}
/>
</>
)}
Expand Down
43 changes: 20 additions & 23 deletions src/renderer/components/filters/FilterSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;

beforeEach(() => {
updateFilterSpy = vi.spyOn(useFiltersStore.getState(), 'updateFilter');
});

afterEach(() => {
vi.clearAllMocks();
});

describe('should render itself & its children', () => {
it('with detailed notifications enabled', () => {
Expand Down Expand Up @@ -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(
<FilterSection
Expand All @@ -110,25 +115,17 @@ describe('renderer/components/filters/FilterSection.tsx', () => {
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();
});
});
43 changes: 31 additions & 12 deletions src/renderer/components/filters/FilterSection.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,31 +8,45 @@ 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<T extends FilterSettingsValue> {
export interface FilterSectionProps<K extends keyof FiltersState> {
id: string;
title: string;
icon: Icon;
filter: Filter<T>;
filterSetting: keyof FilterSettingsState;
filter: Filter<FiltersState[K][number]>;
filterSetting: K;
tooltip?: ReactNode;
layout?: 'horizontal' | 'vertical';
}

export const FilterSection = <T extends FilterSettingsValue>({
const FilterSectionComponent = <K extends keyof FiltersState>({
id,
title,
icon,
filter,
filterSetting,
tooltip,
layout = 'vertical',
}: FilterSectionProps<T>) => {
const { updateFilter, settings, notifications } = useAppContext();
}: FilterSectionProps<K>) => {
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<FiltersState[K][number], number>();
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 (
<fieldset id={id}>
Expand All @@ -56,7 +70,7 @@ export const FilterSection = <T extends FilterSettingsValue>({
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)
Expand All @@ -69,8 +83,8 @@ export const FilterSection = <T extends FilterSettingsValue>({
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 (
<Checkbox
Expand All @@ -94,3 +108,8 @@ export const FilterSection = <T extends FilterSettingsValue>({
</fieldset>
);
};

// Memoize the component to prevent unnecessary re-renders
export const FilterSection = memo(
FilterSectionComponent,
) as typeof FilterSectionComponent;
Loading