Skip to content

Commit 7ef4870

Browse files
committed
feat: search notifications
Signed-off-by: Adam Setch <[email protected]>
1 parent e0b1568 commit 7ef4870

File tree

5 files changed

+217
-235
lines changed

5 files changed

+217
-235
lines changed

src/renderer/components/filters/SearchFilter.tsx

Lines changed: 46 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,18 @@ import {
88
RepoIcon,
99
SearchIcon,
1010
} from '@primer/octicons-react';
11-
import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
11+
import { Box, Stack, Text } from '@primer/react';
1212

1313
import { AppContext } from '../../context/App';
1414
import { IconColor, type SearchToken, Size } from '../../types';
15-
import {
16-
hasExcludeSearchFilters,
17-
hasIncludeSearchFilters,
18-
SEARCH_PREFIXES,
19-
} from '../../utils/notifications/filters/search';
15+
import { hasExcludeSearchFilters, hasIncludeSearchFilters } from '../../utils/notifications/filters/search';
2016
import { Title } from '../primitives/Title';
2117
import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
18+
import { TokenSearchInput } from './TokenSearchInput';
19+
import { cn } from '../../utils/cn';
2220

2321
type InputToken = { id: number; text: string };
2422

25-
import { SearchFilterSuggestions } from './SearchFilterSuggestions';
26-
27-
const tokenEvents = ['Enter', 'Tab', ' ', ','];
28-
29-
function parseRawValue(raw: string): string | null {
30-
const value = raw.trim();
31-
if (!value) return null;
32-
// Find a matching prefix (prefixes already include the colon)
33-
const matched = SEARCH_PREFIXES.find((p) =>
34-
value.toLowerCase().startsWith(p),
35-
);
36-
if (!matched) return null;
37-
const rest = value.substring(matched.length);
38-
if (rest.length === 0) return null;
39-
return `${matched}${rest}`; // matched already has ':'
40-
}
41-
4223
export const SearchFilter: FC = () => {
4324
const { updateFilter, settings } = useContext(AppContext);
4425

@@ -56,89 +37,39 @@ export const SearchFilter: FC = () => {
5637
const mapValuesToTokens = (values: string[]): InputToken[] =>
5738
values.map((value, index) => ({ id: index, text: value }));
5839

59-
const [includeSearchTokens, setIncludeSearchTokens] = useState<InputToken[]>(
60-
mapValuesToTokens(settings.filterIncludeSearchTokens),
61-
);
62-
63-
const addIncludeSearchToken = (
64-
event:
65-
| React.KeyboardEvent<HTMLInputElement>
66-
| React.FocusEvent<HTMLInputElement>,
67-
) => {
68-
const raw = (event.target as HTMLInputElement).value;
69-
const value = parseRawValue(raw);
40+
const [includeSearchTokens, setIncludeSearchTokens] = useState<InputToken[]>(mapValuesToTokens(settings.filterIncludeSearchTokens));
7041

71-
if (value && !includeSearchTokens.some((v) => v.text === value)) {
72-
setIncludeSearchTokens([
73-
...includeSearchTokens,
74-
{ id: includeSearchTokens.length, text: value },
75-
]);
76-
updateFilter('filterIncludeSearchTokens', value as SearchToken, true);
77-
(event.target as HTMLInputElement).value = '';
78-
}
42+
const addIncludeSearchToken = (value: string) => {
43+
if (!value || includeSearchTokens.some((v) => v.text === value)) return;
44+
const nextId = includeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1;
45+
setIncludeSearchTokens([...includeSearchTokens, { id: nextId, text: value }]);
46+
updateFilter('filterIncludeSearchTokens', value as SearchToken, true);
7947
};
8048

8149
const removeIncludeSearchToken = (tokenId: string | number) => {
8250
const value = includeSearchTokens.find((v) => v.id === tokenId)?.text || '';
83-
updateFilter('filterIncludeSearchTokens', value as SearchToken, false);
51+
if (value) updateFilter('filterIncludeSearchTokens', value as SearchToken, false);
8452
setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId));
8553
};
8654

87-
const [includeInputValue, setIncludeInputValue] = useState('');
88-
const [showIncludeSuggestions, setShowIncludeSuggestions] = useState(false);
89-
90-
const includeSearchTokensKeyDown = (
91-
event: React.KeyboardEvent<HTMLInputElement>,
92-
) => {
93-
if (tokenEvents.includes(event.key)) {
94-
addIncludeSearchToken(event);
95-
setShowIncludeSuggestions(false);
96-
} else if (event.key === 'ArrowDown') {
97-
setShowIncludeSuggestions(true);
98-
}
99-
};
100-
101-
const [excludeSearchTokens, setExcludeSearchTokens] = useState<InputToken[]>(
102-
mapValuesToTokens(settings.filterExcludeSearchTokens),
103-
);
55+
// now handled inside TokenSearchInput
10456

105-
const addExcludeSearchToken = (
106-
event:
107-
| React.KeyboardEvent<HTMLInputElement>
108-
| React.FocusEvent<HTMLInputElement>,
109-
) => {
110-
const raw = (event.target as HTMLInputElement).value;
111-
const value = parseRawValue(raw);
57+
const [excludeSearchTokens, setExcludeSearchTokens] = useState<InputToken[]>(mapValuesToTokens(settings.filterExcludeSearchTokens));
11258

113-
if (value && !excludeSearchTokens.some((v) => v.text === value)) {
114-
setExcludeSearchTokens([
115-
...excludeSearchTokens,
116-
{ id: excludeSearchTokens.length, text: value },
117-
]);
118-
updateFilter('filterExcludeSearchTokens', value as SearchToken, true);
119-
(event.target as HTMLInputElement).value = '';
120-
}
59+
const addExcludeSearchToken = (value: string) => {
60+
if (!value || excludeSearchTokens.some((v) => v.text === value)) return;
61+
const nextId = excludeSearchTokens.reduce((m, t) => Math.max(m, t.id), -1) + 1;
62+
setExcludeSearchTokens([...excludeSearchTokens, { id: nextId, text: value }]);
63+
updateFilter('filterExcludeSearchTokens', value as SearchToken, true);
12164
};
12265

12366
const removeExcludeSearchToken = (tokenId: string | number) => {
12467
const value = excludeSearchTokens.find((v) => v.id === tokenId)?.text || '';
125-
updateFilter('filterExcludeSearchTokens', value as SearchToken, false);
68+
if (value) updateFilter('filterExcludeSearchTokens', value as SearchToken, false);
12669
setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId));
12770
};
12871

129-
const [excludeInputValue, setExcludeInputValue] = useState('');
130-
const [showExcludeSuggestions, setShowExcludeSuggestions] = useState(false);
131-
132-
const excludeSearchTokensKeyDown = (
133-
event: React.KeyboardEvent<HTMLInputElement>,
134-
) => {
135-
if (tokenEvents.includes(event.key)) {
136-
addExcludeSearchToken(event);
137-
setShowExcludeSuggestions(false);
138-
} else if (event.key === 'ArrowDown') {
139-
setShowExcludeSuggestions(true);
140-
}
141-
};
72+
// handled by TokenSearchInput
14273

14374
// Basic suggestions for prefixes
14475
const fieldsetId = useId();
@@ -154,14 +85,15 @@ export const SearchFilter: FC = () => {
15485
<Stack direction="vertical" gap="condensed">
15586
<Stack direction="horizontal" gap="condensed">
15687
<PersonIcon size={Size.SMALL} />
157-
Author (author:handle)
88+
<Text className={cn("text-gitify-caution", !settings.detailedNotifications && "line-through")}>Author (author:handle)</Text>
15889
</Stack>
15990
<Stack direction="horizontal" gap="condensed">
16091
<OrganizationIcon size={Size.SMALL} />
161-
Organization (org:name)
92+
<Text>Organization (org:name)</Text>
16293
</Stack>
16394
<Stack direction="horizontal" gap="condensed">
164-
<RepoIcon size={Size.SMALL} /> Repository (repo:fullname)
95+
<RepoIcon size={Size.SMALL} />
96+
<Text>Repository (repo:fullname)</Text>
16597
</Stack>
16698
</Stack>
16799
</Box>
@@ -173,110 +105,28 @@ export const SearchFilter: FC = () => {
173105
</Title>
174106

175107
<Stack direction="vertical" gap="condensed">
176-
<Stack
177-
align="center"
178-
className="text-sm"
179-
direction="horizontal"
180-
gap="condensed"
181-
>
182-
<Box className="font-medium text-gitify-font w-20">
183-
<Stack align="center" direction="horizontal" gap="condensed">
184-
<CheckCircleFillIcon className={IconColor.GREEN} />
185-
<Text>Include:</Text>
186-
</Stack>
187-
</Box>
188-
<Box flexGrow={1} position="relative">
189-
<TextInputWithTokens
190-
block
191-
disabled={!settings.detailedNotifications}
192-
onBlur={(e) => {
193-
addIncludeSearchToken(e);
194-
setShowIncludeSuggestions(false);
195-
}}
196-
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
197-
setIncludeInputValue(e.target.value);
198-
// Show suggestions once user starts typing or clears until prefix chosen
199-
const val = e.target.value.trim();
200-
if (!val.includes(':')) {
201-
setShowIncludeSuggestions(true);
202-
} else {
203-
setShowIncludeSuggestions(false);
204-
}
205-
}}
206-
onFocus={(e) => {
207-
if (
208-
!hasExcludeSearchFilters(settings) &&
209-
!!settings.detailedNotifications &&
210-
(e.target as HTMLInputElement).value.trim() === ''
211-
) {
212-
setShowIncludeSuggestions(true);
213-
}
214-
}}
215-
onKeyDown={includeSearchTokensKeyDown}
216-
onTokenRemove={removeIncludeSearchToken}
217-
size="small"
218-
title="Include searches"
219-
tokens={includeSearchTokens}
220-
/>
221-
<SearchFilterSuggestions
222-
inputValue={includeInputValue}
223-
onClose={() => setShowIncludeSuggestions(false)}
224-
open={showIncludeSuggestions}
225-
/>
226-
</Box>
227-
</Stack>
228-
229-
<Stack
230-
align="center"
231-
className="text-sm"
232-
direction="horizontal"
233-
gap="condensed"
234-
>
235-
<Box className="font-medium text-gitify-font w-20">
236-
<Stack align="center" direction="horizontal" gap="condensed">
237-
<NoEntryFillIcon className={IconColor.RED} />
238-
<Text>Exclude:</Text>
239-
</Stack>
240-
</Box>
241-
<Box flexGrow={1} position="relative">
242-
<TextInputWithTokens
243-
block
244-
disabled={!settings.detailedNotifications}
245-
onBlur={(e) => {
246-
addExcludeSearchToken(e);
247-
setShowExcludeSuggestions(false);
248-
}}
249-
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
250-
setExcludeInputValue(e.target.value);
251-
const val = e.target.value.trim();
252-
if (!val.includes(':')) {
253-
setShowExcludeSuggestions(true);
254-
} else {
255-
setShowExcludeSuggestions(false);
256-
}
257-
}}
258-
onFocus={(e) => {
259-
if (
260-
!hasIncludeSearchFilters(settings) &&
261-
!!settings.detailedNotifications &&
262-
(e.target as HTMLInputElement).value.trim() === ''
263-
) {
264-
setShowExcludeSuggestions(true);
265-
}
266-
}}
267-
onKeyDown={excludeSearchTokensKeyDown}
268-
onTokenRemove={removeExcludeSearchToken}
269-
size="small"
270-
title="Exclude searches"
271-
tokens={excludeSearchTokens}
272-
/>
273-
<SearchFilterSuggestions
274-
inputValue={excludeInputValue}
275-
onClose={() => setShowExcludeSuggestions(false)}
276-
open={showExcludeSuggestions}
277-
/>
278-
</Box>
279-
</Stack>
108+
<TokenSearchInput
109+
icon={CheckCircleFillIcon}
110+
iconColorClass={IconColor.GREEN}
111+
label="Include"
112+
onAdd={addIncludeSearchToken}
113+
onRemove={removeIncludeSearchToken}
114+
showSuggestionsOnFocusIfEmpty={
115+
!hasExcludeSearchFilters(settings) && !!settings.detailedNotifications
116+
}
117+
tokens={includeSearchTokens}
118+
/>
119+
<TokenSearchInput
120+
icon={NoEntryFillIcon}
121+
iconColorClass={IconColor.RED}
122+
label="Exclude"
123+
onAdd={addExcludeSearchToken}
124+
onRemove={removeExcludeSearchToken}
125+
showSuggestionsOnFocusIfEmpty={
126+
!hasIncludeSearchFilters(settings) && !!settings.detailedNotifications
127+
}
128+
tokens={excludeSearchTokens}
129+
/>
280130
</Stack>
281131
</fieldset>
282132
);

src/renderer/components/filters/SearchFilterSuggestions.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({
1919
inputValue,
2020
onClose,
2121
}) => {
22-
if (!open) return null;
22+
if (!open) {
23+
return null;
24+
}
2325

2426
return (
2527
<Popover caret={false} onOpenChange={onClose} open>

0 commit comments

Comments
 (0)