diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 295c420a8..a05dae93d 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -111,6 +111,8 @@ const mockFilters: FilterSettingsState = { filterUserTypes: [], filterIncludeHandles: [], filterExcludeHandles: [], + filterIncludeOrganizations: [], + filterExcludeOrganizations: [], filterSubjectTypes: [], filterStates: [], filterReasons: [], diff --git a/src/renderer/components/filters/OrganizationFilter.test.tsx b/src/renderer/components/filters/OrganizationFilter.test.tsx new file mode 100644 index 000000000..ab0aff667 --- /dev/null +++ b/src/renderer/components/filters/OrganizationFilter.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { mockSettings } from '../../__mocks__/state-mocks'; +import { AppContext } from '../../context/App'; +import type { SettingsState } from '../../types'; +import { OrganizationFilter } from './OrganizationFilter'; + +const mockUpdateFilter = jest.fn(); + +describe('components/filters/OrganizationFilter.tsx', () => { + beforeEach(() => { + mockUpdateFilter.mockReset(); + }); + + it('should render itself & its children', () => { + const props = { + updateFilter: mockUpdateFilter, + settings: mockSettings, + }; + + render( + + + , + ); + + expect(screen.getByText('Organizations')).toBeInTheDocument(); + expect(screen.getByText('Include:')).toBeInTheDocument(); + expect(screen.getByText('Exclude:')).toBeInTheDocument(); + }); + + describe('Include organizations', () => { + it('should handle organization includes', async () => { + const props = { + updateFilter: mockUpdateFilter, + settings: mockSettings, + }; + + render( + + + , + ); + + await userEvent.type( + screen.getByTitle('Include organizations'), + 'microsoft{enter}', + ); + + expect(mockUpdateFilter).toHaveBeenCalledWith( + 'filterIncludeOrganizations', + 'microsoft', + true, + ); + }); + + it('should not allow duplicate include organizations', async () => { + const props = { + updateFilter: mockUpdateFilter, + settings: { + ...mockSettings, + filterIncludeOrganizations: ['microsoft'], + } as SettingsState, + }; + + render( + + + , + ); + + await userEvent.type( + screen.getByTitle('Include organizations'), + 'microsoft{enter}', + ); + + expect(mockUpdateFilter).toHaveBeenCalledTimes(0); + }); + }); + + describe('Exclude organizations', () => { + it('should handle organization excludes', async () => { + const props = { + updateFilter: mockUpdateFilter, + settings: mockSettings, + }; + + render( + + + , + ); + + await userEvent.type( + screen.getByTitle('Exclude organizations'), + 'github{enter}', + ); + + expect(mockUpdateFilter).toHaveBeenCalledWith( + 'filterExcludeOrganizations', + 'github', + true, + ); + }); + + it('should not allow duplicate exclude organizations', async () => { + const props = { + updateFilter: mockUpdateFilter, + settings: { + ...mockSettings, + filterExcludeOrganizations: ['github'], + } as SettingsState, + }; + + render( + + + , + ); + + await userEvent.type( + screen.getByTitle('Exclude organizations'), + 'github{enter}', + ); + + expect(mockUpdateFilter).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/renderer/components/filters/OrganizationFilter.tsx b/src/renderer/components/filters/OrganizationFilter.tsx new file mode 100644 index 000000000..369087f06 --- /dev/null +++ b/src/renderer/components/filters/OrganizationFilter.tsx @@ -0,0 +1,206 @@ +import { type FC, useContext, useEffect, useState } from 'react'; + +import { + CheckCircleFillIcon, + NoEntryFillIcon, + OrganizationIcon, +} from '@primer/octicons-react'; +import { Box, Stack, Text, TextInputWithTokens } from '@primer/react'; + +import { AppContext } from '../../context/App'; +import { IconColor, type Organization } from '../../types'; +import { + hasExcludeOrganizationFilters, + hasIncludeOrganizationFilters, +} from '../../utils/notifications/filters/organizations'; +import { Tooltip } from '../fields/Tooltip'; +import { Title } from '../primitives/Title'; + +type InputToken = { + id: number; + text: string; +}; + +const tokenEvents = ['Enter', 'Tab', ' ', ',']; + +export const OrganizationFilter: FC = () => { + const { updateFilter, settings } = useContext(AppContext); + + // biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on organization filter changes + useEffect(() => { + if (!hasIncludeOrganizationFilters(settings)) { + setIncludeOrganizations([]); + } + + if (!hasExcludeOrganizationFilters(settings)) { + setExcludeOrganizations([]); + } + }, [ + settings.filterIncludeOrganizations, + settings.filterExcludeOrganizations, + ]); + + const mapValuesToTokens = (values: string[]): InputToken[] => { + return values.map((value, index) => ({ + id: index, + text: value, + })); + }; + + const [includeOrganizations, setIncludeOrganizations] = useState< + InputToken[] + >(mapValuesToTokens(settings.filterIncludeOrganizations)); + + const addIncludeOrganizationsToken = ( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) => { + const value = (event.target as HTMLInputElement).value.trim(); + + if ( + value.length > 0 && + !includeOrganizations.some((v) => v.text === value) + ) { + setIncludeOrganizations([ + ...includeOrganizations, + { id: includeOrganizations.length, text: value }, + ]); + updateFilter('filterIncludeOrganizations', value as Organization, true); + + (event.target as HTMLInputElement).value = ''; + } + }; + + const removeIncludeOrganizationToken = (tokenId: string | number) => { + const value = + includeOrganizations.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterIncludeOrganizations', value as Organization, false); + + setIncludeOrganizations( + includeOrganizations.filter((v) => v.id !== tokenId), + ); + }; + + const includeOrganizationsKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (tokenEvents.includes(event.key)) { + addIncludeOrganizationsToken(event); + } + }; + + const [excludeOrganizations, setExcludeOrganizations] = useState< + InputToken[] + >(mapValuesToTokens(settings.filterExcludeOrganizations)); + + const addExcludeOrganizationsToken = ( + event: + | React.KeyboardEvent + | React.FocusEvent, + ) => { + const value = (event.target as HTMLInputElement).value.trim(); + + if ( + value.length > 0 && + !excludeOrganizations.some((v) => v.text === value) + ) { + setExcludeOrganizations([ + ...excludeOrganizations, + { id: excludeOrganizations.length, text: value }, + ]); + updateFilter('filterExcludeOrganizations', value as Organization, true); + + (event.target as HTMLInputElement).value = ''; + } + }; + + const removeExcludeOrganizationToken = (tokenId: string | number) => { + const value = + excludeOrganizations.find((v) => v.id === tokenId)?.text || ''; + updateFilter('filterExcludeOrganizations', value as Organization, false); + + setExcludeOrganizations( + excludeOrganizations.filter((v) => v.id !== tokenId), + ); + }; + + const excludeOrganizationsKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (tokenEvents.includes(event.key)) { + addExcludeOrganizationsToken(event); + } + }; + + return ( +
+ + Organizations + + Filter notifications by organization. + + } + /> + + + + + + + Include: + + + + + + + + + + Exclude: + + + + + +
+ ); +}; diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index f1b041d77..2f750c985 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -104,6 +104,8 @@ export const defaultFilters: FilterSettingsState = { filterUserTypes: [], filterIncludeHandles: [], filterExcludeHandles: [], + filterIncludeOrganizations: [], + filterExcludeOrganizations: [], filterSubjectTypes: [], filterStates: [], filterReasons: [], @@ -193,6 +195,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { settings.filterUserTypes, settings.filterIncludeHandles, settings.filterExcludeHandles, + settings.filterIncludeOrganizations, + settings.filterExcludeOrganizations, settings.filterReasons, ]); diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index cce5d8d5e..ebd7c9658 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -3,6 +3,7 @@ import { type FC, useContext } from 'react'; import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; import { Button, Stack, Tooltip } from '@primer/react'; +import { OrganizationFilter } from '../components/filters/OrganizationFilter'; import { ReasonFilter } from '../components/filters/ReasonFilter'; import { StateFilter } from '../components/filters/StateFilter'; import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter'; @@ -27,6 +28,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 672797719..dae095aab 100644 --- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap @@ -511,6 +511,230 @@ exports[`renderer/routes/Filters.tsx General should render itself & its children +
+
+ +
+
+ +

+ Organizations +

+
+
+
+
+ +
+
+
+
+
+
+ + + Include: + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Exclude: + +
+
+ +
+
+ +
+
+
+
+
+
diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 90398e322..9960c43b1 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -34,6 +34,8 @@ export type Link = Branded; export type UserHandle = Branded; +export type Organization = Branded; + export type Status = 'loading' | 'success' | 'error'; export interface Account { @@ -58,6 +60,7 @@ export type FilterValue = | Reason | UserType | UserHandle + | Organization | FilterStateType | SubjectType; @@ -101,6 +104,8 @@ export interface FilterSettingsState { filterUserTypes: UserType[]; filterIncludeHandles: string[]; filterExcludeHandles: string[]; + filterIncludeOrganizations: string[]; + filterExcludeOrganizations: string[]; filterSubjectTypes: SubjectType[]; filterStates: FilterStateType[]; filterReasons: Reason[]; diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 4a3e2c5eb..d51a232e5 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -122,6 +122,76 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(result.length).toBe(1); expect(result).toEqual([mockNotifications[1]]); }); + + it('should filter notifications that match include organization', async () => { + // Initialize repository owner structure if it doesn't exist + if (!mockNotifications[0].repository) { + mockNotifications[0].repository = {} as any; + } + if (!mockNotifications[0].repository.owner) { + mockNotifications[0].repository.owner = {} as any; + } + if (!mockNotifications[1].repository) { + mockNotifications[1].repository = {} as any; + } + if (!mockNotifications[1].repository.owner) { + mockNotifications[1].repository.owner = {} as any; + } + + mockNotifications[0].repository.owner.login = 'microsoft'; + mockNotifications[1].repository.owner.login = 'github'; + + // Apply base filtering first (where organization filtering now happens) + let result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterIncludeOrganizations: ['microsoft'], + }); + + // Then apply detailed filtering + result = filterDetailedNotifications(result, { + ...mockSettings, + detailedNotifications: true, + filterIncludeOrganizations: ['microsoft'], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); + + it('should filter notifications that match exclude organization', async () => { + // Initialize repository owner structure if it doesn't exist + if (!mockNotifications[0].repository) { + mockNotifications[0].repository = {} as any; + } + if (!mockNotifications[0].repository.owner) { + mockNotifications[0].repository.owner = {} as any; + } + if (!mockNotifications[1].repository) { + mockNotifications[1].repository = {} as any; + } + if (!mockNotifications[1].repository.owner) { + mockNotifications[1].repository.owner = {} as any; + } + + mockNotifications[0].repository.owner.login = 'microsoft'; + mockNotifications[1].repository.owner.login = 'github'; + + // Apply base filtering first (where organization filtering now happens) + let result = filterBaseNotifications(mockNotifications, { + ...mockSettings, + filterExcludeOrganizations: ['github'], + }); + + // Then apply detailed filtering + result = filterDetailedNotifications(result, { + ...mockSettings, + detailedNotifications: true, + filterExcludeOrganizations: ['github'], + }); + + expect(result.length).toBe(1); + expect(result).toEqual([mockNotifications[0]]); + }); }); }); @@ -154,6 +224,22 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { expect(hasAnyFiltersSet(settings)).toBe(true); }); + it('non-default organization includes filters', () => { + const settings: SettingsState = { + ...defaultSettings, + filterIncludeOrganizations: ['microsoft'], + }; + expect(hasAnyFiltersSet(settings)).toBe(true); + }); + + it('non-default organization excludes filters', () => { + const settings: SettingsState = { + ...defaultSettings, + filterExcludeOrganizations: ['github'], + }; + expect(hasAnyFiltersSet(settings)).toBe(true); + }); + it('non-default subject type filters', () => { const settings: SettingsState = { ...defaultSettings, diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index 6f2e3c0db..5961dbcc7 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -6,8 +6,11 @@ import type { } from '../../../typesGitHub'; import { filterNotificationByHandle, + filterNotificationByOrganization, hasExcludeHandleFilters, + hasExcludeOrganizationFilters, hasIncludeHandleFilters, + hasIncludeOrganizationFilters, reasonFilter, stateFilter, subjectTypeFilter, @@ -21,6 +24,9 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; + passesFilters = + passesFilters && passesOrganizationFilters(notification, settings); + if (subjectTypeFilter.hasFilters(settings)) { passesFilters = passesFilters && @@ -65,6 +71,8 @@ export function hasAnyFiltersSet(settings: SettingsState): boolean { userTypeFilter.hasFilters(settings) || hasIncludeHandleFilters(settings) || hasExcludeHandleFilters(settings) || + hasIncludeOrganizationFilters(settings) || + hasExcludeOrganizationFilters(settings) || subjectTypeFilter.hasFilters(settings) || stateFilter.hasFilters(settings) || reasonFilter.hasFilters(settings) @@ -104,6 +112,31 @@ function passesUserFilters( return passesFilters; } +function passesOrganizationFilters( + notification: Notification, + settings: SettingsState, +): boolean { + let passesFilters = true; + + if (hasIncludeOrganizationFilters(settings)) { + passesFilters = + passesFilters && + settings.filterIncludeOrganizations.some((organization) => + filterNotificationByOrganization(notification, organization), + ); + } + + if (hasExcludeOrganizationFilters(settings)) { + passesFilters = + passesFilters && + !settings.filterExcludeOrganizations.some((organization) => + filterNotificationByOrganization(notification, organization), + ); + } + + return passesFilters; +} + function passesStateFilter( notification: Notification, settings: SettingsState, diff --git a/src/renderer/utils/notifications/filters/index.ts b/src/renderer/utils/notifications/filters/index.ts index c2a1e0550..422bd4e12 100644 --- a/src/renderer/utils/notifications/filters/index.ts +++ b/src/renderer/utils/notifications/filters/index.ts @@ -1,4 +1,5 @@ export * from './handles'; +export * from './organizations'; export * from './reason'; export * from './state'; export * from './subjectType'; diff --git a/src/renderer/utils/notifications/filters/organizations.test.ts b/src/renderer/utils/notifications/filters/organizations.test.ts new file mode 100644 index 000000000..d520e1653 --- /dev/null +++ b/src/renderer/utils/notifications/filters/organizations.test.ts @@ -0,0 +1,46 @@ +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Notification } from '../../../typesGitHub'; +import { + filterNotificationByOrganization, + hasExcludeOrganizationFilters, + hasIncludeOrganizationFilters, +} from './organizations'; + +describe('utils/notifications/filters/organizations.ts', () => { + it('should check if include organization filters exist', () => { + const settingsWithInclude = { + ...mockSettings, + filterIncludeOrganizations: ['microsoft'], + }; + + expect(hasIncludeOrganizationFilters(mockSettings)).toBe(false); + expect(hasIncludeOrganizationFilters(settingsWithInclude)).toBe(true); + }); + + it('should check if exclude organization filters exist', () => { + const settingsWithExclude = { + ...mockSettings, + filterExcludeOrganizations: ['github'], + }; + + expect(hasExcludeOrganizationFilters(mockSettings)).toBe(false); + expect(hasExcludeOrganizationFilters(settingsWithExclude)).toBe(true); + }); + + it('should filter notification by organization', () => { + const notification = { + repository: { + owner: { + login: 'microsoft', + }, + }, + } as Notification; + + expect(filterNotificationByOrganization(notification, 'microsoft')).toBe( + true, + ); + expect(filterNotificationByOrganization(notification, 'github')).toBe( + false, + ); + }); +}); diff --git a/src/renderer/utils/notifications/filters/organizations.ts b/src/renderer/utils/notifications/filters/organizations.ts new file mode 100644 index 000000000..5084fa574 --- /dev/null +++ b/src/renderer/utils/notifications/filters/organizations.ts @@ -0,0 +1,20 @@ +import type { SettingsState } from '../../../types'; +import type { Notification } from '../../../typesGitHub'; + +export function hasIncludeOrganizationFilters(settings: SettingsState) { + return settings.filterIncludeOrganizations.length > 0; +} + +export function hasExcludeOrganizationFilters(settings: SettingsState) { + return settings.filterExcludeOrganizations.length > 0; +} + +export function filterNotificationByOrganization( + notification: Notification, + organizationName: string, +): boolean { + return ( + notification.repository.owner.login.toLowerCase() === + organizationName.toLowerCase() + ); +}