Skip to content

Commit 4ac7d00

Browse files
committed
feat: search notifications
Signed-off-by: Adam Setch <[email protected]>
1 parent fc1375d commit 4ac7d00

File tree

4 files changed

+219
-77
lines changed

4 files changed

+219
-77
lines changed

src/renderer/components/filters/SearchFilter.tsx

Lines changed: 190 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,65 @@ import {
88
RepoIcon,
99
SearchIcon,
1010
} from '@primer/octicons-react';
11-
import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
11+
import {
12+
ActionList,
13+
Box,
14+
Popover,
15+
Stack,
16+
Text,
17+
TextInputWithTokens,
18+
} from '@primer/react';
1219

1320
import { AppContext } from '../../context/App';
1421
import { IconColor, type SearchToken, Size } from '../../types';
1522
import {
1623
hasExcludeSearchFilters,
1724
hasIncludeSearchFilters,
25+
SEARCH_PREFIXES,
1826
} from '../../utils/notifications/filters/search';
1927
import { Tooltip } from '../fields/Tooltip';
2028
import { Title } from '../primitives/Title';
2129
import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning';
2230

23-
type InputToken = {
24-
id: number;
25-
text: string;
31+
type InputToken = { id: number; text: string };
32+
33+
type Qualifier = {
34+
key: string; // the qualifier prefix shown to user (author, org, repo)
35+
description: string;
36+
example: string;
2637
};
2738

39+
const QUALIFIERS: Qualifier[] = [
40+
{
41+
key: 'author:',
42+
description: 'Filter by notification author',
43+
example: 'author:octocat',
44+
},
45+
{
46+
key: 'org:',
47+
description: 'Filter by organization owner',
48+
example: 'org:microsoft',
49+
},
50+
{
51+
key: 'repo:',
52+
description: 'Filter by repository full name',
53+
example: 'repo:gitify-app/gitify',
54+
},
55+
];
56+
2857
const tokenEvents = ['Enter', 'Tab', ' ', ','];
2958

3059
function parseRawValue(raw: string): string | null {
3160
const value = raw.trim();
3261
if (!value) return null;
33-
if (!value.includes(':')) return null; // must include prefix already
34-
const [prefix, rest] = value.split(':');
35-
if (!['author', 'org', 'repo'].includes(prefix) || rest.length === 0)
36-
return null;
37-
return `${prefix}:${rest}`;
62+
// Find a matching prefix (prefixes already include the colon)
63+
const matched = SEARCH_PREFIXES.find((p) =>
64+
value.toLowerCase().startsWith(p),
65+
);
66+
if (!matched) return null;
67+
const rest = value.substring(matched.length);
68+
if (rest.length === 0) return null;
69+
return `${matched}${rest}`; // matched already has ':'
3870
}
3971

4072
export const SearchFilter: FC = () => {
@@ -82,11 +114,17 @@ export const SearchFilter: FC = () => {
82114
setIncludeSearchTokens(includeSearchTokens.filter((v) => v.id !== tokenId));
83115
};
84116

117+
const [includeInputValue, setIncludeInputValue] = useState('');
118+
const [showIncludeSuggestions, setShowIncludeSuggestions] = useState(false);
119+
85120
const includeSearchTokensKeyDown = (
86121
event: React.KeyboardEvent<HTMLInputElement>,
87122
) => {
88123
if (tokenEvents.includes(event.key)) {
89124
addIncludeSearchToken(event);
125+
setShowIncludeSuggestions(false);
126+
} else if (event.key === 'ArrowDown') {
127+
setShowIncludeSuggestions(true);
90128
}
91129
};
92130

@@ -118,11 +156,17 @@ export const SearchFilter: FC = () => {
118156
setExcludeSearchTokens(excludeSearchTokens.filter((v) => v.id !== tokenId));
119157
};
120158

159+
const [excludeInputValue, setExcludeInputValue] = useState('');
160+
const [showExcludeSuggestions, setShowExcludeSuggestions] = useState(false);
161+
121162
const excludeSearchTokensKeyDown = (
122163
event: React.KeyboardEvent<HTMLInputElement>,
123164
) => {
124165
if (tokenEvents.includes(event.key)) {
125166
addExcludeSearchToken(event);
167+
setShowExcludeSuggestions(false);
168+
} else if (event.key === 'ArrowDown') {
169+
setShowExcludeSuggestions(true);
126170
}
127171
};
128172

@@ -171,20 +215,75 @@ export const SearchFilter: FC = () => {
171215
<Text>Include:</Text>
172216
</Stack>
173217
</Box>
174-
<TextInputWithTokens
175-
block
176-
disabled={
177-
!settings.detailedNotifications ||
178-
hasExcludeSearchFilters(settings)
179-
}
180-
onBlur={addIncludeSearchToken}
181-
onKeyDown={includeSearchTokensKeyDown}
182-
onTokenRemove={removeIncludeSearchToken}
183-
placeholder="author:octocat org:microsoft repo:gitify"
184-
size="small"
185-
title="Include searches"
186-
tokens={includeSearchTokens}
187-
/>
218+
<Box flexGrow={1} position="relative">
219+
<TextInputWithTokens
220+
block
221+
disabled={
222+
!settings.detailedNotifications ||
223+
hasExcludeSearchFilters(settings)
224+
}
225+
onBlur={(e) => {
226+
addIncludeSearchToken(e);
227+
setShowIncludeSuggestions(false);
228+
}}
229+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
230+
setIncludeInputValue(e.target.value);
231+
// Show suggestions once user starts typing or clears until prefix chosen
232+
const val = e.target.value.trim();
233+
if (!val.includes(':')) {
234+
setShowIncludeSuggestions(true);
235+
} else {
236+
setShowIncludeSuggestions(false);
237+
}
238+
}}
239+
onKeyDown={includeSearchTokensKeyDown}
240+
onTokenRemove={removeIncludeSearchToken}
241+
placeholder="author:octocat org:microsoft repo:gitify"
242+
size="small"
243+
title="Include searches"
244+
tokens={includeSearchTokens}
245+
/>
246+
{showIncludeSuggestions && (
247+
<Popover
248+
caret={false}
249+
onOpenChange={() => setShowIncludeSuggestions(false)}
250+
open
251+
>
252+
<Popover.Content sx={{ p: 0, mt: 1, width: '100%' }}>
253+
<ActionList>
254+
{QUALIFIERS.filter(
255+
(q) =>
256+
q.key.startsWith(includeInputValue.toLowerCase()) ||
257+
includeInputValue === '',
258+
).map((q) => (
259+
<ActionList.Item
260+
key={q.key}
261+
onSelect={() => {
262+
setIncludeInputValue(`${q.key}:`);
263+
const inputEl =
264+
document.querySelector<HTMLInputElement>(
265+
`fieldset#${fieldsetId} input[title='Include searches']`,
266+
);
267+
if (inputEl) {
268+
inputEl.value = `${q.key}:`;
269+
inputEl.focus();
270+
}
271+
setShowIncludeSuggestions(false);
272+
}}
273+
>
274+
<Stack direction="vertical" gap="none">
275+
<Text>{q.key}</Text>
276+
<Text className="text-xs opacity-70">
277+
{q.description}
278+
</Text>
279+
</Stack>
280+
</ActionList.Item>
281+
))}
282+
</ActionList>
283+
</Popover.Content>
284+
</Popover>
285+
)}
286+
</Box>
188287
</Stack>
189288

190289
<Stack
@@ -199,20 +298,74 @@ export const SearchFilter: FC = () => {
199298
<Text>Exclude:</Text>
200299
</Stack>
201300
</Box>
202-
<TextInputWithTokens
203-
block
204-
disabled={
205-
!settings.detailedNotifications ||
206-
hasIncludeSearchFilters(settings)
207-
}
208-
onBlur={addExcludeSearchToken}
209-
onKeyDown={excludeSearchTokensKeyDown}
210-
onTokenRemove={removeExcludeSearchToken}
211-
placeholder="author:spambot org:legacycorp repo:oldrepo"
212-
size="small"
213-
title="Exclude searches"
214-
tokens={excludeSearchTokens}
215-
/>
301+
<Box flexGrow={1} position="relative">
302+
<TextInputWithTokens
303+
block
304+
disabled={
305+
!settings.detailedNotifications ||
306+
hasIncludeSearchFilters(settings)
307+
}
308+
onBlur={(e) => {
309+
addExcludeSearchToken(e);
310+
setShowExcludeSuggestions(false);
311+
}}
312+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
313+
setExcludeInputValue(e.target.value);
314+
const val = e.target.value.trim();
315+
if (!val.includes(':')) {
316+
setShowExcludeSuggestions(true);
317+
} else {
318+
setShowExcludeSuggestions(false);
319+
}
320+
}}
321+
onKeyDown={excludeSearchTokensKeyDown}
322+
onTokenRemove={removeExcludeSearchToken}
323+
placeholder="author:spambot org:legacycorp repo:oldrepo"
324+
size="small"
325+
title="Exclude searches"
326+
tokens={excludeSearchTokens}
327+
/>
328+
{showExcludeSuggestions && (
329+
<Popover
330+
caret={false}
331+
onOpenChange={() => setShowExcludeSuggestions(false)}
332+
open
333+
>
334+
<Popover.Content sx={{ p: 0, mt: 1, width: '100%' }}>
335+
<ActionList>
336+
{QUALIFIERS.filter(
337+
(q) =>
338+
q.key.startsWith(excludeInputValue.toLowerCase()) ||
339+
excludeInputValue === '',
340+
).map((q) => (
341+
<ActionList.Item
342+
key={q.key}
343+
onSelect={() => {
344+
setExcludeInputValue(`${q.key}:`);
345+
const inputEl =
346+
document.querySelector<HTMLInputElement>(
347+
`fieldset#${fieldsetId} input[title='Exclude searches']`,
348+
);
349+
if (inputEl) {
350+
inputEl.value = `${q.key}:`;
351+
inputEl.focus();
352+
}
353+
setShowExcludeSuggestions(false);
354+
}}
355+
>
356+
<Stack direction="vertical" gap="none">
357+
<Text>{q.key}</Text>
358+
<Text className="text-xs opacity-70">
359+
{q.description}
360+
</Text>
361+
</Stack>
362+
</ActionList.Item>
363+
))}
364+
</ActionList>
365+
</Popover.Content>
366+
</Popover>
367+
)}
368+
</Box>
216369
</Stack>
217370
</Stack>
218371
</fieldset>

0 commit comments

Comments
 (0)