From a2ffd6c5edc5b01930b5fb17ba06294cfb870755 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 26 Feb 2025 07:36:20 -0500 Subject: [PATCH] feat: filter by notification type (#1860) * feat: filter by notification type Signed-off-by: Adam Setch * feat: filter by notification type Signed-off-by: Adam Setch * feat: filter by notification type Signed-off-by: Adam Setch --------- Signed-off-by: Adam Setch --- src/renderer/__mocks__/state-mocks.ts | 1 + .../components/filters/StateFilter.tsx | 4 +- .../filters/SubjectTypeFilter.test.tsx | 96 ++ .../components/filters/SubjectTypeFilter.tsx | 75 + .../__snapshots__/StateFilter.test.tsx.snap | 28 +- .../SubjectTypeFilter.test.tsx.snap | 1229 +++++++++++++++++ src/renderer/context/App.tsx | 1 + src/renderer/routes/Filters.tsx | 2 + .../__snapshots__/Filters.test.tsx.snap | 314 ++++- src/renderer/types.ts | 17 +- .../notifications/filters/filter.test.ts | 20 + .../utils/notifications/filters/filter.ts | 13 + .../utils/notifications/filters/state.ts | 5 +- .../notifications/filters/subjectType.ts | 75 + 14 files changed, 1861 insertions(+), 19 deletions(-) create mode 100644 src/renderer/components/filters/SubjectTypeFilter.test.tsx create mode 100644 src/renderer/components/filters/SubjectTypeFilter.tsx create mode 100644 src/renderer/components/filters/__snapshots__/SubjectTypeFilter.test.tsx.snap create mode 100644 src/renderer/utils/notifications/filters/subjectType.ts diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 4a879e857..a12d6ce96 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -109,6 +109,7 @@ const mockFilters: FilterSettingsState = { filterUserTypes: [], filterIncludeHandles: [], filterExcludeHandles: [], + filterSubjectTypes: [], filterStates: [], filterReasons: [], }; diff --git a/src/renderer/components/filters/StateFilter.tsx b/src/renderer/components/filters/StateFilter.tsx index 5d6fce034..4a453035d 100644 --- a/src/renderer/components/filters/StateFilter.tsx +++ b/src/renderer/components/filters/StateFilter.tsx @@ -1,6 +1,6 @@ import { type FC, useContext } from 'react'; -import { BellIcon } from '@primer/octicons-react'; +import { IssueOpenedIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; import { AppContext } from '../../context/App'; @@ -21,7 +21,7 @@ export const StateFilter: FC = () => { return (
- State + State { + const updateFilter = jest.fn(); + + it('should render itself & its children', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should be able to toggle subject type - none already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Issue')); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterSubjectTypes', + 'Issue', + true, + ); + + expect( + screen.getByLabelText('Issue').parentNode.parentNode, + ).toMatchSnapshot(); + }); + + it('should be able to toggle subject type - some filters already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Pull Request')); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterSubjectTypes', + 'PullRequest', + true, + ); + + expect( + screen.getByLabelText('Pull Request').parentNode.parentNode, + ).toMatchSnapshot(); + }); +}); diff --git a/src/renderer/components/filters/SubjectTypeFilter.tsx b/src/renderer/components/filters/SubjectTypeFilter.tsx new file mode 100644 index 000000000..3761c44a9 --- /dev/null +++ b/src/renderer/components/filters/SubjectTypeFilter.tsx @@ -0,0 +1,75 @@ +import { type FC, useContext } from 'react'; + +import { BellIcon } from '@primer/octicons-react'; +import { Stack, Text } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import type { SubjectType } from '../../typesGitHub'; +import { + FILTERS_SUBJECT_TYPES, + getSubjectTypeDetails, + getSubjectTypeFilterCount, + isSubjectTypeFilterSet, +} from '../../utils/notifications/filters/subjectType'; +import { Checkbox } from '../fields/Checkbox'; +import { Tooltip } from '../fields/Tooltip'; +import { Title } from '../primitives/Title'; + +export const SubjectTypeFilter: FC = () => { + const { updateFilter, settings, notifications } = useContext(AppContext); + + return ( +
+ + Type + + Filter notifications by type. + + } + /> + + + + {Object.keys(FILTERS_SUBJECT_TYPES).map((subjectType: SubjectType) => { + const subjectTypeDetails = getSubjectTypeDetails(subjectType); + const subjectTypeTitle = subjectTypeDetails.title; + const subjectTypeDescription = subjectTypeDetails.description; + const isSubjectTypeChecked = isSubjectTypeFilterSet( + settings, + subjectType, + ); + const subjectTypeCount = getSubjectTypeFilterCount( + notifications, + subjectType, + ); + + return ( + + updateFilter( + 'filterSubjectTypes', + subjectType, + evt.target.checked, + ) + } + tooltip={ + subjectTypeDescription ? ( + {subjectTypeDescription} + ) : null + } + disabled={!settings.detailedNotifications} + counter={subjectTypeCount} + /> + ); + })} + +
+ ); +}; diff --git a/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap index e0537e360..ea0c2df73 100644 --- a/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap +++ b/src/renderer/components/filters/__snapshots__/StateFilter.test.tsx.snap @@ -419,7 +419,7 @@ exports[`renderer/components/filters/StateFilter.tsx should render itself & its >

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +`; + +exports[`renderer/components/filters/SubjectTypeFilter.tsx should be able to toggle subject type - some filters already set 1`] = ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+`; + +exports[`renderer/components/filters/SubjectTypeFilter.tsx should render itself & its children 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ +
+
+ +

+ Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +  ( + 2 + ) + +
+
+ + + + +  ( + 1 + ) + +
+
+ + + + +  ( + 1 + ) + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ , + "container":
+
+
+ +
+
+ +

+ Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +  ( + 2 + ) + +
+
+ + + + +  ( + 1 + ) + +
+
+ + + + +  ( + 1 + ) + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 8e0199276..d3421e6c2 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -101,6 +101,7 @@ export const defaultFilters: FilterSettingsState = { filterUserTypes: [], filterIncludeHandles: [], filterExcludeHandles: [], + filterSubjectTypes: [], filterStates: [], filterReasons: [], }; diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index b6906c9aa..0b801c943 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -5,6 +5,7 @@ import { Button, Stack, Tooltip } from '@primer/react'; import { ReasonFilter } from '../components/filters/ReasonFilter'; import { StateFilter } from '../components/filters/StateFilter'; +import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter'; import { UserHandleFilter } from '../components/filters/UserHandleFilter'; import { UserTypeFilter } from '../components/filters/UserTypeFilter'; import { Contents } from '../components/layout/Contents'; @@ -26,6 +27,7 @@ export const FiltersRoute: FC = () => { + diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap index dd4639376..68dd6c092 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -500,7 +500,7 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children

+

+ Type +

+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+

{ expect(result).toEqual([mockNotifications[0]]); }); + it('should filter notifications by subject type when provided', async () => { + mockNotifications[0].subject.type = 'Issue'; + mockNotifications[1].subject.type = 'PullRequest'; + const result = filterNotifications(mockNotifications, { + ...mockSettings, + filterSubjectTypes: ['Issue'], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + it('should filter notifications by state when provided', async () => { mockNotifications[0].subject.state = 'open'; mockNotifications[1].subject.state = 'closed'; @@ -134,6 +146,14 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(hasAnyFiltersSet(settings)).toBe(true); }); + it('non-default subject type filters', () => { + const settings = { + ...defaultSettings, + filterSubjectTypes: ['Issue'], + } as SettingsState; + expect(hasAnyFiltersSet(settings)).toBe(true); + }); + it('non-default state filters', () => { const settings = { ...defaultSettings, diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index 1c951527a..ae369ccc6 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -7,6 +7,10 @@ import { } from './handles'; import { filterNotificationByReason, hasReasonFilters } from './reason'; import { filterNotificationByState, hasStateFilters } from './state'; +import { + filterNotificationBySubjectType, + hasSubjectTypeFilters, +} from './subjectType'; import { filterNotificationByUserType, hasUserTypeFilters } from './userType'; export function filterNotifications( @@ -50,6 +54,14 @@ export function filterNotifications( } } + if (hasSubjectTypeFilters(settings)) { + passesFilters = + passesFilters && + settings.filterSubjectTypes.some((subjectType) => + filterNotificationBySubjectType(notification, subjectType), + ); + } + if (hasReasonFilters(settings)) { passesFilters = passesFilters && @@ -67,6 +79,7 @@ export function hasAnyFiltersSet(settings: SettingsState): boolean { hasUserTypeFilters(settings) || hasIncludeHandleFilters(settings) || hasExcludeHandleFilters(settings) || + hasSubjectTypeFilters(settings) || hasStateFilters(settings) || hasReasonFilters(settings) ); diff --git a/src/renderer/utils/notifications/filters/state.ts b/src/renderer/utils/notifications/filters/state.ts index cf2004314..f87709edc 100644 --- a/src/renderer/utils/notifications/filters/state.ts +++ b/src/renderer/utils/notifications/filters/state.ts @@ -25,10 +25,7 @@ export const FILTERS_STATE_TYPES: Record = { title: 'Other', description: 'Catch all for any other notification states', }, -} as Partial> as Record< - FilterStateType, - TypeDetails ->; +}; export function getStateDetails(stateType: FilterStateType): TypeDetails { return FILTERS_STATE_TYPES[stateType]; diff --git a/src/renderer/utils/notifications/filters/subjectType.ts b/src/renderer/utils/notifications/filters/subjectType.ts new file mode 100644 index 000000000..4b25c18ac --- /dev/null +++ b/src/renderer/utils/notifications/filters/subjectType.ts @@ -0,0 +1,75 @@ +import type { + AccountNotifications, + SettingsState, + TypeDetails, +} from '../../../types'; +import type { Notification, SubjectType } from '../../../typesGitHub'; + +export const FILTERS_SUBJECT_TYPES: Record = { + CheckSuite: { + title: 'Check Suite', + }, + Commit: { + title: 'Commit', + }, + Discussion: { + title: 'Discussion', + }, + Issue: { + title: 'Issue', + }, + PullRequest: { + title: 'Pull Request', + }, + Release: { + title: 'Release', + }, + RepositoryDependabotAlertsThread: { + title: 'Dependabot Alert', + }, + RepositoryInvitation: { + title: 'Invitation', + }, + RepositoryVulnerabilityAlert: { + title: 'Vulnerability Alert', + }, + WorkflowRun: { + title: 'Workflow Run', + }, +}; + +export function getSubjectTypeDetails(subjectType: SubjectType): TypeDetails { + return FILTERS_SUBJECT_TYPES[subjectType]; +} + +export function hasSubjectTypeFilters(settings: SettingsState) { + return settings.filterSubjectTypes.length > 0; +} + +export function isSubjectTypeFilterSet( + settings: SettingsState, + subjectType: SubjectType, +) { + return settings.filterSubjectTypes.includes(subjectType); +} + +export function getSubjectTypeFilterCount( + notifications: AccountNotifications[], + subjectType: SubjectType, +) { + return notifications.reduce( + (sum, account) => + sum + + account.notifications.filter((n) => + filterNotificationBySubjectType(n, subjectType), + ).length, + 0, + ); +} + +export function filterNotificationBySubjectType( + notification: Notification, + subjectType: SubjectType, +): boolean { + return notification.subject.type === subjectType; +}