diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 315050f43..7905387d8 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -106,7 +106,9 @@ const mockSystemSettings: SystemSettingsState = { }; const mockFilters: FilterSettingsState = { - hideBots: false, + filterUserTypes: [], + filterIncludeHandles: [], + filterExcludeHandles: [], filterReasons: [], }; diff --git a/src/renderer/components/AllRead.test.tsx b/src/renderer/components/AllRead.test.tsx index 23afec8c1..80ff5409a 100644 --- a/src/renderer/components/AllRead.test.tsx +++ b/src/renderer/components/AllRead.test.tsx @@ -14,9 +14,7 @@ describe('renderer/components/AllRead.tsx', () => { const tree = render( @@ -35,7 +33,6 @@ describe('renderer/components/AllRead.tsx', () => { settings: { ...mockSettings, filterReasons: ['author'], - hideBots: true, }, }} > diff --git a/src/renderer/components/AllRead.tsx b/src/renderer/components/AllRead.tsx index 71af304eb..4109d4d33 100644 --- a/src/renderer/components/AllRead.tsx +++ b/src/renderer/components/AllRead.tsx @@ -2,7 +2,7 @@ import { type FC, useContext, useMemo } from 'react'; import { AppContext } from '../context/App'; import { Constants } from '../utils/constants'; -import { hasAnyFiltersSet } from '../utils/filters'; +import { hasAnyFiltersSet } from '../utils/notifications/filters/filter'; import { EmojiSplash } from './layout/EmojiSplash'; interface IAllRead { diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 0c5de1d2c..49c511401 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -16,12 +16,12 @@ import { APPLICATION } from '../../shared/constants'; import { AppContext } from '../context/App'; import { quitApp } from '../utils/comms'; import { Constants } from '../utils/constants'; -import { hasAnyFiltersSet } from '../utils/filters'; import { openGitHubIssues, openGitHubNotifications, openGitHubPulls, } from '../utils/links'; +import { hasAnyFiltersSet } from '../utils/notifications/filters/filter'; import { getNotificationCount } from '../utils/notifications/notifications'; import { LogoIcon } from './icons/LogoIcon'; diff --git a/src/renderer/components/filters/ReasonFilter.test.tsx b/src/renderer/components/filters/ReasonFilter.test.tsx new file mode 100644 index 000000000..85ebd67a2 --- /dev/null +++ b/src/renderer/components/filters/ReasonFilter.test.tsx @@ -0,0 +1,84 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import { ReasonFilter } from './ReasonFilter'; + +describe('renderer/components/filters/ReasonFilter.tsx', () => { + const updateFilter = jest.fn(); + + it('should render itself & its children', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should be able to toggle reason type - none already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Mentioned')); + + expect(updateFilter).toHaveBeenCalledWith('filterReasons', 'mention', true); + + expect( + screen.getByLabelText('Mentioned').parentNode.parentNode, + ).toMatchSnapshot(); + }); + + it('should be able to toggle reason type - some filters already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Mentioned')); + + expect(updateFilter).toHaveBeenCalledWith('filterReasons', 'mention', true); + + expect( + screen.getByLabelText('Mentioned').parentNode.parentNode, + ).toMatchSnapshot(); + }); +}); diff --git a/src/renderer/components/filters/ReasonFilter.tsx b/src/renderer/components/filters/ReasonFilter.tsx new file mode 100644 index 000000000..44950c649 --- /dev/null +++ b/src/renderer/components/filters/ReasonFilter.tsx @@ -0,0 +1,47 @@ +import { type FC, useContext } from 'react'; + +import { NoteIcon } from '@primer/octicons-react'; +import { Stack, Text } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import type { Reason } from '../../typesGitHub'; +import { + getReasonFilterCount, + isReasonFilterSet, +} from '../../utils/notifications/filters/reason'; +import { FORMATTED_REASONS, getReasonDetails } from '../../utils/reason'; +import { Checkbox } from '../fields/Checkbox'; +import { Title } from '../primitives/Title'; + +export const ReasonFilter: FC = () => { + const { updateFilter, settings, notifications } = useContext(AppContext); + + return ( +
+ Reason + + {Object.keys(FORMATTED_REASONS).map((reason: Reason) => { + const reasonDetails = getReasonDetails(reason); + const reasonTitle = reasonDetails.title; + const reasonDescription = reasonDetails.description; + const isReasonChecked = isReasonFilterSet(settings, reason); + const reasonCount = getReasonFilterCount(notifications, reason); + + return ( + + updateFilter('filterReasons', reason, evt.target.checked) + } + tooltip={{reasonDescription}} + counter={reasonCount} + /> + ); + })} + +
+ ); +}; diff --git a/src/renderer/components/filters/UserHandleFilter.test.tsx b/src/renderer/components/filters/UserHandleFilter.test.tsx new file mode 100644 index 000000000..0179adc9f --- /dev/null +++ b/src/renderer/components/filters/UserHandleFilter.test.tsx @@ -0,0 +1,194 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import type { SettingsState } from '../../types'; +import { UserHandleFilter } from './UserHandleFilter'; + +describe('renderer/components/filters/UserHandleFilter.tsx', () => { + const updateFilter = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render itself & its children - detailed notifications enabled', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - detailed notifications disabled', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + describe('Include user handles', () => { + it('should be able to filter by include user handle - none already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.change(screen.getByTitle('Include handles'), { + target: { value: 'github-user' }, + }); + + fireEvent.keyDown(screen.getByTitle('Include handles'), { + key: 'Enter', + code: 'Enter', + }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterIncludeHandles', + 'github-user', + true, + ); + }); + + it('should not allow duplicate include user handle', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.change(screen.getByTitle('Include handles'), { + target: { value: 'github-user' }, + }); + + fireEvent.keyDown(screen.getByTitle('Include handles'), { + key: 'Enter', + code: 'Enter', + }); + + expect(updateFilter).toHaveBeenCalledTimes(0); + }); + }); + + describe('Exclude user handles', () => { + it('should be able to filter by exclude user handle - none already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.change(screen.getByTitle('Exclude handles'), { + target: { value: 'github-user' }, + }); + + fireEvent.keyDown(screen.getByTitle('Exclude handles'), { + key: 'Enter', + code: 'Enter', + }); + + expect(updateFilter).toHaveBeenCalledWith( + 'filterExcludeHandles', + 'github-user', + true, + ); + }); + + it('should not allow duplicate exclude user handle', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.change(screen.getByTitle('Exclude handles'), { + target: { value: 'github-user' }, + }); + + fireEvent.keyDown(screen.getByTitle('Exclude handles'), { + key: 'Enter', + code: 'Enter', + }); + + expect(updateFilter).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/renderer/components/filters/UserHandleFilter.tsx b/src/renderer/components/filters/UserHandleFilter.tsx new file mode 100644 index 000000000..a8a0ee4c2 --- /dev/null +++ b/src/renderer/components/filters/UserHandleFilter.tsx @@ -0,0 +1,186 @@ +import { type FC, useContext, useEffect, useState } from 'react'; + +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; + +import { + CheckCircleFillIcon, + MentionIcon, + NoEntryFillIcon, +} from '@primer/octicons-react'; +import { AppContext } from '../../context/App'; +import { IconColor, type UserHandle } from '../../types'; +import { + hasExcludeHandleFilters, + hasIncludeHandleFilters, +} from '../../utils/notifications/filters/handles'; +import { Tooltip } from '../fields/Tooltip'; +import { Title } from '../primitives/Title'; + +type InputToken = { + id: number; + text: string; +}; + +const tokenEvents = ['Enter', 'Tab', ' ', ',']; + +export const UserHandleFilter: FC = () => { + const { updateFilter, settings } = useContext(AppContext); + + // biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on handle filter changes + useEffect(() => { + if (!hasIncludeHandleFilters(settings)) { + setIncludeHandles([]); + } + + if (!hasExcludeHandleFilters(settings)) { + setExcludeHandles([]); + } + }, [settings.filterIncludeHandles, settings.filterExcludeHandles]); + + const mapValuesToTokens = (values: string[]): InputToken[] => { + return values.map((value, index) => ({ + id: index, + text: value, + })); + }; + + const [includeHandles, setIncludeHandles] = useState( + mapValuesToTokens(settings.filterIncludeHandles), + ); + + const removeIncludeHandleToken = (tokenId: string | number) => { + const value = includeHandles.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterIncludeHandles', value as UserHandle, false); + + setIncludeHandles(includeHandles.filter((v) => v.id !== tokenId)); + }; + + const includeHandlesKeyDown = ( + event: React.KeyboardEvent, + ) => { + const value = (event.target as HTMLInputElement).value.trim(); + + if ( + tokenEvents.includes(event.key) && + !includeHandles.some((v) => v.text === value) && + value.length > 0 + ) { + event.preventDefault(); + + setIncludeHandles([ + ...includeHandles, + { id: includeHandles.length, text: value }, + ]); + updateFilter('filterIncludeHandles', value as UserHandle, true); + + (event.target as HTMLInputElement).value = ''; + } + }; + + const [excludeHandles, setExcludeHandles] = useState( + mapValuesToTokens(settings.filterExcludeHandles), + ); + + const removeExcludeHandleToken = (tokenId: string | number) => { + const value = excludeHandles.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterExcludeHandles', value as UserHandle, false); + + setExcludeHandles(excludeHandles.filter((v) => v.id !== tokenId)); + }; + + const excludeHandlesKeyDown = ( + event: React.KeyboardEvent, + ) => { + const value = (event.target as HTMLInputElement).value.trim(); + + if ( + tokenEvents.includes(event.key) && + !excludeHandles.some((v) => v.text === value) && + value.length > 0 + ) { + event.preventDefault(); + + setExcludeHandles([ + ...excludeHandles, + { id: excludeHandles.length, text: value }, + ]); + updateFilter('filterExcludeHandles', value as UserHandle, true); + + (event.target as HTMLInputElement).value = ''; + } + }; + + return ( +
+ + Handles + + Filter notifications by user handle. + + ⚠️ This filter requires the{' '} + Detailed Notifications setting to be + enabled. + + + } + /> + + + + + + + Include: + + + + + + + + + + Exclude: + + + + + +
+ ); +}; diff --git a/src/renderer/components/filters/UserTypeFilter.test.tsx b/src/renderer/components/filters/UserTypeFilter.test.tsx new file mode 100644 index 000000000..6034df435 --- /dev/null +++ b/src/renderer/components/filters/UserTypeFilter.test.tsx @@ -0,0 +1,108 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import type { SettingsState } from '../../types'; +import { UserTypeFilter } from './UserTypeFilter'; + +describe('renderer/components/filters/UserTypeFilter.tsx', () => { + const updateFilter = jest.fn(); + + it('should render itself & its children - detailed notifications enabled', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should render itself & its children - detailed notifications disabled', () => { + const tree = render( + + + + + , + ); + + expect(tree).toMatchSnapshot(); + }); + + it('should be able to toggle user type - none already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('User')); + + expect(updateFilter).toHaveBeenCalledWith('filterUserTypes', 'User', true); + + expect( + screen.getByLabelText('User').parentNode.parentNode, + ).toMatchSnapshot(); + }); + + it('should be able to toggle user type - some filters already set', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + fireEvent.click(screen.getByLabelText('Bot')); + + expect(updateFilter).toHaveBeenCalledWith('filterUserTypes', 'Bot', true); + + expect( + screen.getByLabelText('Bot').parentNode.parentNode, + ).toMatchSnapshot(); + }); +}); diff --git a/src/renderer/components/filters/UserTypeFilter.tsx b/src/renderer/components/filters/UserTypeFilter.tsx new file mode 100644 index 000000000..516c2b771 --- /dev/null +++ b/src/renderer/components/filters/UserTypeFilter.tsx @@ -0,0 +1,90 @@ +import { type FC, useContext } from 'react'; + +import { + DependabotIcon, + FeedPersonIcon, + OrganizationIcon, + PersonIcon, +} from '@primer/octicons-react'; +import { Box, Stack, Text } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import { Size } from '../../types'; +import type { UserType } from '../../typesGitHub'; +import { + FILTERS_USER_TYPES, + getUserTypeDetails, + getUserTypeFilterCount, + isUserTypeFilterSet, +} from '../../utils/notifications/filters/userType'; +import { Checkbox } from '../fields/Checkbox'; +import { Tooltip } from '../fields/Tooltip'; +import { Title } from '../primitives/Title'; + +export const UserTypeFilter: FC = () => { + const { updateFilter, settings, notifications } = useContext(AppContext); + + return ( +
+ + User Type + + Filter notifications by user type: + + + + + User + + + + Bot accounts such as @dependabot, @renovate, @netlify, etc + + + + Organization + + + + + ⚠️ This filter requires the{' '} + Detailed Notifications setting to be + enabled. + + + } + /> + + + + {Object.keys(FILTERS_USER_TYPES).map((userType: UserType) => { + const userTypeDetails = getUserTypeDetails(userType); + const userTypeTitle = userTypeDetails.title; + const userTypeDescription = userTypeDetails.description; + const isUserTypeChecked = isUserTypeFilterSet(settings, userType); + const userTypeCount = getUserTypeFilterCount(notifications, userType); + + return ( + + updateFilter('filterUserTypes', userType, evt.target.checked) + } + tooltip={ + userTypeDescription ? {userTypeDescription} : null + } + disabled={!settings.detailedNotifications} + counter={userTypeCount} + /> + ); + })} + +
+ ); +}; diff --git a/src/renderer/components/filters/__snapshots__/ReasonFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/ReasonFilter.test.tsx.snap new file mode 100644 index 000000000..3c8ce5fcc --- /dev/null +++ b/src/renderer/components/filters/__snapshots__/ReasonFilter.test.tsx.snap @@ -0,0 +1,2899 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderer/components/filters/ReasonFilter.tsx should be able to toggle reason type - none already set 1`] = ` +
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+`; + +exports[`renderer/components/filters/ReasonFilter.tsx should be able to toggle reason type - some filters already set 1`] = ` +
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+`; + +exports[`renderer/components/filters/ReasonFilter.tsx should render itself & its children 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+ +
+
+ +

+ Reason +

+
+
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+
+ , + "container":
+
+ +
+
+ +

+ Reason +

+
+
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+
, + "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/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap new file mode 100644 index 000000000..ffba23026 --- /dev/null +++ b/src/renderer/components/filters/__snapshots__/UserHandleFilter.test.tsx.snap @@ -0,0 +1,1031 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderer/components/filters/UserHandleFilter.tsx should render itself & its children - detailed notifications disabled 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ +
+
+ +

+ Handles +

+
+
+
+
+ +
+
+
+
+
+
+ + + Include: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Exclude: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ , + "container":
+
+
+ +
+
+ +

+ Handles +

+
+
+
+
+ +
+
+
+
+
+
+ + + Include: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Exclude: + +
+
+ +
+
+ +
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/UserHandleFilter.tsx should render itself & its children - detailed notifications enabled 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ +
+
+ +

+ Handles +

+
+
+
+
+ +
+
+
+
+
+
+ + + Include: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Exclude: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ , + "container":
+
+
+ +
+
+ +

+ Handles +

+
+
+
+
+ +
+
+
+
+
+
+ + + Include: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Exclude: + +
+
+ +
+
+ +
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap new file mode 100644 index 000000000..37d0a2883 --- /dev/null +++ b/src/renderer/components/filters/__snapshots__/UserTypeFilter.test.tsx.snap @@ -0,0 +1,1042 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderer/components/filters/UserTypeFilter.tsx should be able to toggle user type - none already set 1`] = ` +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+`; + +exports[`renderer/components/filters/UserTypeFilter.tsx should be able to toggle user type - some filters already set 1`] = ` +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+`; + +exports[`renderer/components/filters/UserTypeFilter.tsx should render itself & its children - detailed notifications disabled 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ +
+
+ +

+ User Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ , + "container":
+
+
+ +
+
+ +

+ User Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`renderer/components/filters/UserTypeFilter.tsx should render itself & its children - detailed notifications enabled 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ +
+
+ +

+ User Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ , + "container":
+
+
+ +
+
+ +

+ User Type +

+
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
, + "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/components/notifications/NotificationFooter.tsx b/src/renderer/components/notifications/NotificationFooter.tsx index dba9c20ac..f5c9b25bb 100644 --- a/src/renderer/components/notifications/NotificationFooter.tsx +++ b/src/renderer/components/notifications/NotificationFooter.tsx @@ -6,7 +6,7 @@ import { Opacity, Size } from '../../types'; import type { Notification } from '../../typesGitHub'; import { cn } from '../../utils/cn'; import { openUserProfile } from '../../utils/links'; -import { formatReason } from '../../utils/reason'; +import { getReasonDetails } from '../../utils/reason'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; import { MetricGroup } from '../metrics/MetricGroup'; @@ -17,7 +17,7 @@ interface INotificationFooter { export const NotificationFooter: FC = ({ notification, }: INotificationFooter) => { - const reason = formatReason(notification.reason); + const reason = getReasonDetails(notification.reason); return ( { } as AuthState, settings: { ...mockSettings, - hideBots: defaultSettings.hideBots, + filterUserTypes: defaultSettings.filterUserTypes, + filterIncludeHandles: defaultSettings.filterIncludeHandles, + filterExcludeHandles: defaultSettings.filterExcludeHandles, filterReasons: defaultSettings.filterReasons, }, }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 68973bcd5..9630303d2 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -19,6 +19,7 @@ import { type AppearanceSettingsState, type AuthState, type FilterSettingsState, + type FilterValue, type GitifyError, GroupBy, type NotificationSettingsState, @@ -97,7 +98,9 @@ const defaultSystemSettings: SystemSettingsState = { }; export const defaultFilters: FilterSettingsState = { - hideBots: false, + filterUserTypes: [], + filterIncludeHandles: [], + filterExcludeHandles: [], filterReasons: [], }; @@ -129,6 +132,11 @@ interface AppContextState { clearFilters: () => void; resetSettings: () => void; updateSetting: (name: keyof SettingsState, value: SettingsValue) => void; + updateFilter: ( + name: keyof FilterSettingsState, + value: FilterValue, + checked: boolean, + ) => void; } export const AppContext = createContext>({}); @@ -164,7 +172,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { // biome-ignore lint/correctness/useExhaustiveDependencies: We only want fetchNotifications to be called for particular state changes useEffect(() => { fetchNotifications({ auth, settings }); - }, [auth.accounts, settings.filterReasons, settings.hideBots]); + }, [ + auth.accounts, + settings.filterUserTypes, + settings.filterIncludeHandles, + settings.filterExcludeHandles, + settings.filterReasons, + ]); useInterval(() => { fetchNotifications({ auth, settings }); @@ -225,6 +239,17 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth, settings], ); + const updateFilter = useCallback( + (name: keyof FilterSettingsState, value: FilterValue, checked: boolean) => { + const updatedFilters = checked + ? [...settings[name], value] + : settings[name].filter((item) => item !== value); + + updateSetting(name, updatedFilters as FilterValue[]); + }, + [updateSetting, settings], + ); + const isLoggedIn = useMemo(() => { return hasAccounts(auth); }, [auth]); @@ -356,6 +381,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { clearFilters, resetSettings, updateSetting, + updateFilter, }} > {children} diff --git a/src/renderer/routes/Filters.test.tsx b/src/renderer/routes/Filters.test.tsx index 52502dae6..982fb9e62 100644 --- a/src/renderer/routes/Filters.test.tsx +++ b/src/renderer/routes/Filters.test.tsx @@ -11,7 +11,6 @@ jest.mock('react-router-dom', () => ({ })); describe('renderer/routes/Filters.tsx', () => { - const updateSetting = jest.fn(); const clearFilters = jest.fn(); const fetchNotifications = jest.fn(); @@ -64,161 +63,6 @@ describe('renderer/routes/Filters.tsx', () => { }); }); - describe('Users section', () => { - it('should not be able to toggle the hideBots checkbox when detailedNotifications is disabled', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect( - screen - .getByLabelText('Hide notifications from Bot accounts') - .closest('input'), - ).toHaveProperty('disabled', true); - - // click the checkbox - fireEvent.click( - screen.getByLabelText('Hide notifications from Bot accounts'), - ); - - // check if the checkbox is still unchecked - expect(updateSetting).not.toHaveBeenCalled(); - - expect( - screen.getByLabelText('Hide notifications from Bot accounts').parentNode - .parentNode, - ).toMatchSnapshot(); - }); - - it('should be able to toggle the hideBots checkbox when detailedNotifications is enabled', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - expect( - screen - .getByLabelText('Hide notifications from Bot accounts') - .closest('input'), - ).toHaveProperty('disabled', false); - - // click the checkbox - fireEvent.click( - screen.getByLabelText('Hide notifications from Bot accounts'), - ); - - // check if the checkbox is still unchecked - expect(updateSetting).toHaveBeenCalledWith('hideBots', true); - - expect( - screen.getByLabelText('Hide notifications from Bot accounts').parentNode - .parentNode, - ).toMatchSnapshot(); - }); - }); - - describe('Reasons section', () => { - it('should be able to toggle reason type - none already set', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - // click the checkbox - fireEvent.click(screen.getByLabelText('Mentioned')); - - // check if the checkbox is still unchecked - expect(updateSetting).toHaveBeenCalledWith('filterReasons', ['mention']); - - expect( - screen.getByLabelText('Mentioned').parentNode.parentNode, - ).toMatchSnapshot(); - }); - - it('should be able to toggle reason type - some filters already set', async () => { - await act(async () => { - render( - - - - - , - ); - }); - - // click the checkbox - fireEvent.click(screen.getByLabelText('Mentioned')); - - // check if the checkbox is still unchecked - expect(updateSetting).toHaveBeenCalledWith('filterReasons', [ - 'security_alert', - 'mention', - ]); - - expect( - screen.getByLabelText('Mentioned').parentNode.parentNode, - ).toMatchSnapshot(); - }); - }); - describe('Footer section', () => { it('should clear filters', async () => { await act(async () => { diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index b0445ecad..9f8836a90 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -1,43 +1,19 @@ import { type FC, useContext } from 'react'; -import { - FeedPersonIcon, - FilterIcon, - FilterRemoveIcon, - NoteIcon, -} from '@primer/octicons-react'; -import { Box, Button, Stack, Text, Tooltip } from '@primer/react'; +import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; +import { Button, Stack, Tooltip } from '@primer/react'; -import { Checkbox } from '../components/fields/Checkbox'; +import { ReasonFilter } from '../components/filters/ReasonFilter'; +import { UserHandleFilter } from '../components/filters/UserHandleFilter'; +import { UserTypeFilter } from '../components/filters/UserTypeFilter'; import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; -import { Title } from '../components/primitives/Title'; import { AppContext } from '../context/App'; -import type { Reason } from '../typesGitHub'; -import { getNonBotFilterCount, getReasonFilterCount } from '../utils/filters'; -import { FORMATTED_REASONS, formatReason } from '../utils/reason'; export const FiltersRoute: FC = () => { - const { settings, clearFilters, updateSetting, notifications } = - useContext(AppContext); - - const updateReasonFilter = (reason: Reason, checked: boolean) => { - let reasons: Reason[] = [...settings.filterReasons]; - - if (checked) { - reasons.push(reason); - } else { - reasons = reasons.filter((r) => r !== reason); - } - - updateSetting('filterReasons', reasons); - }; - - const isReasonFilterSet = (reason: Reason) => { - return settings.filterReasons.includes(reason); - }; + const { clearFilters } = useContext(AppContext); return ( @@ -46,73 +22,18 @@ export const FiltersRoute: FC = () => { -
- Users - - - settings.detailedNotifications && - updateSetting('hideBots', evt.target.checked) - } - disabled={!settings.detailedNotifications} - tooltip={ - - - Hide notifications from GitHub Bot accounts, such as - @dependabot, @renovate, @netlify, etc - - - ⚠️ This filter requires the{' '} - Detailed Notifications setting to be - enabled. - - - } - counter={getNonBotFilterCount(notifications)} - /> -
- -
- Reason - - - Note: If no reasons are selected, all notifications will be shown. - - - - - {Object.keys(FORMATTED_REASONS).map((reason: Reason) => { - const reasonTitle = formatReason(reason).title; - const isReasonChecked = isReasonFilterSet(reason); - const reasonDescription = formatReason(reason).description; - const reasonCount = getReasonFilterCount(notifications, reason); - - return ( - - updateReasonFilter(reason, evt.target.checked) - } - tooltip={{reasonDescription}} - counter={reasonCount} - /> - ); - })} - -
+ + + + +