Skip to content

Commit 5405124

Browse files
vallieressetchy
andauthored
feat: added the organization filter (#2174)
* added the organization filter * case insensitive orgs, moved filter code, removed detailed notif warning * small tweaks Signed-off-by: Adam Setch <[email protected]> --------- Signed-off-by: Adam Setch <[email protected]> Co-authored-by: Adam Setch <[email protected]>
1 parent 9a6e383 commit 5405124

File tree

12 files changed

+759
-0
lines changed

12 files changed

+759
-0
lines changed

src/renderer/__mocks__/state-mocks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ const mockFilters: FilterSettingsState = {
111111
filterUserTypes: [],
112112
filterIncludeHandles: [],
113113
filterExcludeHandles: [],
114+
filterIncludeOrganizations: [],
115+
filterExcludeOrganizations: [],
114116
filterSubjectTypes: [],
115117
filterStates: [],
116118
filterReasons: [],
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { mockSettings } from '../../__mocks__/state-mocks';
5+
import { AppContext } from '../../context/App';
6+
import type { SettingsState } from '../../types';
7+
import { OrganizationFilter } from './OrganizationFilter';
8+
9+
const mockUpdateFilter = jest.fn();
10+
11+
describe('components/filters/OrganizationFilter.tsx', () => {
12+
beforeEach(() => {
13+
mockUpdateFilter.mockReset();
14+
});
15+
16+
it('should render itself & its children', () => {
17+
const props = {
18+
updateFilter: mockUpdateFilter,
19+
settings: mockSettings,
20+
};
21+
22+
render(
23+
<AppContext.Provider value={props}>
24+
<OrganizationFilter />
25+
</AppContext.Provider>,
26+
);
27+
28+
expect(screen.getByText('Organizations')).toBeInTheDocument();
29+
expect(screen.getByText('Include:')).toBeInTheDocument();
30+
expect(screen.getByText('Exclude:')).toBeInTheDocument();
31+
});
32+
33+
describe('Include organizations', () => {
34+
it('should handle organization includes', async () => {
35+
const props = {
36+
updateFilter: mockUpdateFilter,
37+
settings: mockSettings,
38+
};
39+
40+
render(
41+
<AppContext.Provider value={props}>
42+
<OrganizationFilter />
43+
</AppContext.Provider>,
44+
);
45+
46+
await userEvent.type(
47+
screen.getByTitle('Include organizations'),
48+
'microsoft{enter}',
49+
);
50+
51+
expect(mockUpdateFilter).toHaveBeenCalledWith(
52+
'filterIncludeOrganizations',
53+
'microsoft',
54+
true,
55+
);
56+
});
57+
58+
it('should not allow duplicate include organizations', async () => {
59+
const props = {
60+
updateFilter: mockUpdateFilter,
61+
settings: {
62+
...mockSettings,
63+
filterIncludeOrganizations: ['microsoft'],
64+
} as SettingsState,
65+
};
66+
67+
render(
68+
<AppContext.Provider value={props}>
69+
<OrganizationFilter />
70+
</AppContext.Provider>,
71+
);
72+
73+
await userEvent.type(
74+
screen.getByTitle('Include organizations'),
75+
'microsoft{enter}',
76+
);
77+
78+
expect(mockUpdateFilter).toHaveBeenCalledTimes(0);
79+
});
80+
});
81+
82+
describe('Exclude organizations', () => {
83+
it('should handle organization excludes', async () => {
84+
const props = {
85+
updateFilter: mockUpdateFilter,
86+
settings: mockSettings,
87+
};
88+
89+
render(
90+
<AppContext.Provider value={props}>
91+
<OrganizationFilter />
92+
</AppContext.Provider>,
93+
);
94+
95+
await userEvent.type(
96+
screen.getByTitle('Exclude organizations'),
97+
'github{enter}',
98+
);
99+
100+
expect(mockUpdateFilter).toHaveBeenCalledWith(
101+
'filterExcludeOrganizations',
102+
'github',
103+
true,
104+
);
105+
});
106+
107+
it('should not allow duplicate exclude organizations', async () => {
108+
const props = {
109+
updateFilter: mockUpdateFilter,
110+
settings: {
111+
...mockSettings,
112+
filterExcludeOrganizations: ['github'],
113+
} as SettingsState,
114+
};
115+
116+
render(
117+
<AppContext.Provider value={props}>
118+
<OrganizationFilter />
119+
</AppContext.Provider>,
120+
);
121+
122+
await userEvent.type(
123+
screen.getByTitle('Exclude organizations'),
124+
'github{enter}',
125+
);
126+
127+
expect(mockUpdateFilter).toHaveBeenCalledTimes(0);
128+
});
129+
});
130+
});
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { type FC, useContext, useEffect, useState } from 'react';
2+
3+
import {
4+
CheckCircleFillIcon,
5+
NoEntryFillIcon,
6+
OrganizationIcon,
7+
} from '@primer/octicons-react';
8+
import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
9+
10+
import { AppContext } from '../../context/App';
11+
import { IconColor, type Organization } from '../../types';
12+
import {
13+
hasExcludeOrganizationFilters,
14+
hasIncludeOrganizationFilters,
15+
} from '../../utils/notifications/filters/organizations';
16+
import { Tooltip } from '../fields/Tooltip';
17+
import { Title } from '../primitives/Title';
18+
19+
type InputToken = {
20+
id: number;
21+
text: string;
22+
};
23+
24+
const tokenEvents = ['Enter', 'Tab', ' ', ','];
25+
26+
export const OrganizationFilter: FC = () => {
27+
const { updateFilter, settings } = useContext(AppContext);
28+
29+
// biome-ignore lint/correctness/useExhaustiveDependencies: we only want to run this effect on organization filter changes
30+
useEffect(() => {
31+
if (!hasIncludeOrganizationFilters(settings)) {
32+
setIncludeOrganizations([]);
33+
}
34+
35+
if (!hasExcludeOrganizationFilters(settings)) {
36+
setExcludeOrganizations([]);
37+
}
38+
}, [
39+
settings.filterIncludeOrganizations,
40+
settings.filterExcludeOrganizations,
41+
]);
42+
43+
const mapValuesToTokens = (values: string[]): InputToken[] => {
44+
return values.map((value, index) => ({
45+
id: index,
46+
text: value,
47+
}));
48+
};
49+
50+
const [includeOrganizations, setIncludeOrganizations] = useState<
51+
InputToken[]
52+
>(mapValuesToTokens(settings.filterIncludeOrganizations));
53+
54+
const addIncludeOrganizationsToken = (
55+
event:
56+
| React.KeyboardEvent<HTMLInputElement>
57+
| React.FocusEvent<HTMLInputElement>,
58+
) => {
59+
const value = (event.target as HTMLInputElement).value.trim();
60+
61+
if (
62+
value.length > 0 &&
63+
!includeOrganizations.some((v) => v.text === value)
64+
) {
65+
setIncludeOrganizations([
66+
...includeOrganizations,
67+
{ id: includeOrganizations.length, text: value },
68+
]);
69+
updateFilter('filterIncludeOrganizations', value as Organization, true);
70+
71+
(event.target as HTMLInputElement).value = '';
72+
}
73+
};
74+
75+
const removeIncludeOrganizationToken = (tokenId: string | number) => {
76+
const value =
77+
includeOrganizations.find((v) => v.id === tokenId)?.text || '';
78+
updateFilter('filterIncludeOrganizations', value as Organization, false);
79+
80+
setIncludeOrganizations(
81+
includeOrganizations.filter((v) => v.id !== tokenId),
82+
);
83+
};
84+
85+
const includeOrganizationsKeyDown = (
86+
event: React.KeyboardEvent<HTMLInputElement>,
87+
) => {
88+
if (tokenEvents.includes(event.key)) {
89+
addIncludeOrganizationsToken(event);
90+
}
91+
};
92+
93+
const [excludeOrganizations, setExcludeOrganizations] = useState<
94+
InputToken[]
95+
>(mapValuesToTokens(settings.filterExcludeOrganizations));
96+
97+
const addExcludeOrganizationsToken = (
98+
event:
99+
| React.KeyboardEvent<HTMLInputElement>
100+
| React.FocusEvent<HTMLInputElement>,
101+
) => {
102+
const value = (event.target as HTMLInputElement).value.trim();
103+
104+
if (
105+
value.length > 0 &&
106+
!excludeOrganizations.some((v) => v.text === value)
107+
) {
108+
setExcludeOrganizations([
109+
...excludeOrganizations,
110+
{ id: excludeOrganizations.length, text: value },
111+
]);
112+
updateFilter('filterExcludeOrganizations', value as Organization, true);
113+
114+
(event.target as HTMLInputElement).value = '';
115+
}
116+
};
117+
118+
const removeExcludeOrganizationToken = (tokenId: string | number) => {
119+
const value =
120+
excludeOrganizations.find((v) => v.id === tokenId)?.text || '';
121+
updateFilter('filterExcludeOrganizations', value as Organization, false);
122+
123+
setExcludeOrganizations(
124+
excludeOrganizations.filter((v) => v.id !== tokenId),
125+
);
126+
};
127+
128+
const excludeOrganizationsKeyDown = (
129+
event: React.KeyboardEvent<HTMLInputElement>,
130+
) => {
131+
if (tokenEvents.includes(event.key)) {
132+
addExcludeOrganizationsToken(event);
133+
}
134+
};
135+
136+
return (
137+
<fieldset id="filter-organizations">
138+
<Stack direction="horizontal" gap="condensed" align="baseline">
139+
<Title icon={OrganizationIcon}>Organizations</Title>
140+
<Tooltip
141+
name="tooltip-filter-organizations"
142+
tooltip={
143+
<Stack direction="vertical" gap="condensed">
144+
<Text>Filter notifications by organization.</Text>
145+
</Stack>
146+
}
147+
/>
148+
</Stack>
149+
<Stack direction="vertical" gap="condensed">
150+
<Stack
151+
direction="horizontal"
152+
gap="condensed"
153+
align="center"
154+
className="text-sm"
155+
>
156+
<Box className="font-medium text-gitify-font w-28">
157+
<Stack direction="horizontal" gap="condensed" align="center">
158+
<CheckCircleFillIcon className={IconColor.GREEN} />
159+
<Text>Include:</Text>
160+
</Stack>
161+
</Box>
162+
<TextInputWithTokens
163+
title="Include organizations"
164+
tokens={includeOrganizations}
165+
onTokenRemove={removeIncludeOrganizationToken}
166+
onKeyDown={includeOrganizationsKeyDown}
167+
onBlur={addIncludeOrganizationsToken}
168+
size="small"
169+
disabled={
170+
!settings.detailedNotifications ||
171+
hasExcludeOrganizationFilters(settings)
172+
}
173+
block
174+
/>
175+
</Stack>
176+
177+
<Stack
178+
direction="horizontal"
179+
gap="condensed"
180+
align="center"
181+
className="text-sm"
182+
>
183+
<Box className="font-medium text-gitify-font w-28">
184+
<Stack direction="horizontal" gap="condensed" align="center">
185+
<NoEntryFillIcon className={IconColor.RED} />
186+
<Text>Exclude:</Text>
187+
</Stack>
188+
</Box>
189+
<TextInputWithTokens
190+
title="Exclude organizations"
191+
tokens={excludeOrganizations}
192+
onTokenRemove={removeExcludeOrganizationToken}
193+
onKeyDown={excludeOrganizationsKeyDown}
194+
onBlur={addExcludeOrganizationsToken}
195+
size="small"
196+
disabled={
197+
!settings.detailedNotifications ||
198+
hasIncludeOrganizationFilters(settings)
199+
}
200+
block
201+
/>
202+
</Stack>
203+
</Stack>
204+
</fieldset>
205+
);
206+
};

src/renderer/context/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export const defaultFilters: FilterSettingsState = {
104104
filterUserTypes: [],
105105
filterIncludeHandles: [],
106106
filterExcludeHandles: [],
107+
filterIncludeOrganizations: [],
108+
filterExcludeOrganizations: [],
107109
filterSubjectTypes: [],
108110
filterStates: [],
109111
filterReasons: [],
@@ -193,6 +195,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
193195
settings.filterUserTypes,
194196
settings.filterIncludeHandles,
195197
settings.filterExcludeHandles,
198+
settings.filterIncludeOrganizations,
199+
settings.filterExcludeOrganizations,
196200
settings.filterReasons,
197201
]);
198202

src/renderer/routes/Filters.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type FC, useContext } from 'react';
33
import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react';
44
import { Button, Stack, Tooltip } from '@primer/react';
55

6+
import { OrganizationFilter } from '../components/filters/OrganizationFilter';
67
import { ReasonFilter } from '../components/filters/ReasonFilter';
78
import { StateFilter } from '../components/filters/StateFilter';
89
import { SubjectTypeFilter } from '../components/filters/SubjectTypeFilter';
@@ -27,6 +28,7 @@ export const FiltersRoute: FC = () => {
2728
<Stack direction="vertical" gap="spacious">
2829
<UserTypeFilter />
2930
<UserHandleFilter />
31+
<OrganizationFilter />
3032
<SubjectTypeFilter />
3133
<StateFilter />
3234
<ReasonFilter />

0 commit comments

Comments
 (0)