Skip to content

Commit 4487404

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

File tree

5 files changed

+70
-102
lines changed

5 files changed

+70
-102
lines changed

src/renderer/components/filters/SearchFilterSuggestions.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { Box, Popover, Stack, Text } from '@primer/react';
55
import { Opacity } from '../../types';
66
import { cn } from '../../utils/cn';
77
import {
8-
BASE_SEARCH_QUALIFIERS,
98
ALL_SEARCH_QUALIFIERS,
9+
BASE_SEARCH_QUALIFIERS,
1010
SEARCH_DELIMITER,
1111
} from '../../utils/notifications/filters/search';
1212

@@ -28,7 +28,9 @@ export const SearchFilterSuggestions: FC<SearchFilterSuggestionsProps> = ({
2828
}
2929

3030
const lower = inputValue.toLowerCase();
31-
const base = isDetailedNotificationsEnabled ? ALL_SEARCH_QUALIFIERS : BASE_SEARCH_QUALIFIERS;
31+
const base = isDetailedNotificationsEnabled
32+
? ALL_SEARCH_QUALIFIERS
33+
: BASE_SEARCH_QUALIFIERS;
3234
const suggestions = base.filter(
3335
(q) => q.prefix.startsWith(lower) || inputValue === '',
3436
);

src/renderer/components/filters/TokenSearchInput.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Box, Stack, Text, TextInputWithTokens } from '@primer/react';
44

55
import type { SearchToken } from '../../types';
66
import {
7-
normalizeSearchInputToToken,
7+
parseSearchInput,
88
SEARCH_DELIMITER,
99
} from '../../utils/notifications/filters/search';
1010
import { SearchFilterSuggestions } from './SearchFilterSuggestions';
@@ -20,7 +20,7 @@ interface TokenSearchInputProps {
2020
onRemove: (token: SearchToken) => void;
2121
}
2222

23-
const tokenEvents = ['Enter', 'Tab', ' ', ','];
23+
const INPUT_KEY_EVENTS = ['Enter', 'Tab', ' ', ','];
2424

2525
export const TokenSearchInput: FC<TokenSearchInputProps> = ({
2626
label,
@@ -43,16 +43,17 @@ export const TokenSearchInput: FC<TokenSearchInputProps> = ({
4343
| React.FocusEvent<HTMLInputElement>,
4444
) {
4545
const raw = (event.target as HTMLInputElement).value;
46-
const value = normalizeSearchInputToToken(raw);
47-
if (value && !tokens.includes(value as SearchToken)) {
48-
onAdd(value as SearchToken);
46+
const parsed = parseSearchInput(raw);
47+
const token = parsed?.token as SearchToken | undefined;
48+
if (token && !tokens.includes(token)) {
49+
onAdd(token);
4950
(event.target as HTMLInputElement).value = '';
5051
setInputValue('');
5152
}
5253
}
5354

5455
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
55-
if (tokenEvents.includes(e.key)) {
56+
if (INPUT_KEY_EVENTS.includes(e.key)) {
5657
tryAddToken(e);
5758
setShowSuggestions(false);
5859
} else if (e.key === 'ArrowDown') {

src/renderer/utils/notifications/filters/filter.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@ import type {
55
SubjectUser,
66
} from '../../../typesGitHub';
77
import {
8+
BASE_SEARCH_QUALIFIERS,
9+
DETAILED_ONLY_SEARCH_QUALIFIERS,
810
filterNotificationBySearchTerm,
911
hasExcludeSearchFilters,
1012
hasIncludeSearchFilters,
1113
reasonFilter,
14+
type SearchQualifier,
1215
stateFilter,
1316
subjectTypeFilter,
1417
userTypeFilter,
15-
type SearchQualifier,
16-
BASE_SEARCH_QUALIFIERS,
17-
DETAILED_ONLY_SEARCH_QUALIFIERS,
1818
} from '.';
1919

20-
21-
2220
export function filterBaseNotifications(
2321
notifications: Notification[],
2422
settings: SettingsState,
@@ -28,7 +26,10 @@ export function filterBaseNotifications(
2826

2927
// Apply base qualifier include/exclude filters (org, repo, etc.)
3028
for (const qualifier of BASE_SEARCH_QUALIFIERS) {
31-
if (!passesFilters) break;
29+
if (!passesFilters) {
30+
break;
31+
}
32+
3233
passesFilters =
3334
passesFilters &&
3435
passesSearchTokenFiltersForQualifier(notification, settings, qualifier);
@@ -140,7 +141,10 @@ function passesUserFilters(
140141

141142
// Apply detailed-only qualifier search token filters (e.g. author)
142143
for (const qualifier of DETAILED_ONLY_SEARCH_QUALIFIERS) {
143-
if (!passesFilters) break;
144+
if (!passesFilters) {
145+
break;
146+
}
147+
144148
passesFilters =
145149
passesFilters &&
146150
passesSearchTokenFiltersForQualifier(notification, settings, qualifier);

src/renderer/utils/notifications/filters/search.test.ts

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,37 @@
11
import { partialMockNotification } from '../../../__mocks__/partial-mocks';
22
import type { Link } from '../../../types';
33
import type { Owner } from '../../../typesGitHub';
4-
import { filterNotificationBySearchTerm, parseSearchToken, ALL_SEARCH_QUALIFIERS } from './search';
4+
import {
5+
ALL_SEARCH_QUALIFIERS,
6+
filterNotificationBySearchTerm,
7+
parseSearchInput,
8+
} from './search';
59

610
// (helper removed – no longer used)
711

812
describe('renderer/utils/notifications/filters/search.ts', () => {
9-
describe('parseSearchToken (prefix matching behavior)', () => {
13+
describe('parseSearchInput (prefix matching behavior)', () => {
1014
it('returns null for empty string', () => {
11-
expect(parseSearchToken('')).toBeNull();
15+
expect(parseSearchInput('')).toBeNull();
1216
});
1317

1418
it('returns null when no qualifier prefix matches', () => {
15-
expect(parseSearchToken('unknown:value')).toBeNull();
16-
expect(parseSearchToken('auth:foo')).toBeNull(); // near miss
19+
expect(parseSearchInput('unknown:value')).toBeNull();
20+
expect(parseSearchInput('auth:foo')).toBeNull(); // near miss
1721
});
1822

1923
it('matches each known qualifier by its exact prefix and additional value', () => {
2024
for (const q of ALL_SEARCH_QUALIFIERS) {
2125
const token = q.prefix + 'someValue';
22-
const parsed = parseSearchToken(token);
26+
const parsed = parseSearchInput(token);
2327
expect(parsed).not.toBeNull();
2428
expect(parsed?.qualifier).toBe(q);
2529
}
2630
});
2731

28-
it('is case-sensitive (does not match mismatched casing)', () => {
29-
// Intentionally alter case of prefix characters
30-
expect(parseSearchToken('Author:foo')).toBeNull();
31-
expect(parseSearchToken('ORG:bar')).toBeNull();
32-
expect(parseSearchToken('Repo:baz')).toBeNull();
33-
});
34-
3532
it('does not match when prefix appears later in the token', () => {
36-
expect(parseSearchToken('xauthor:foo')).toBeNull();
37-
expect(parseSearchToken('xxorg:bar')).toBeNull();
33+
expect(parseSearchInput('xauthor:foo')).toBeNull();
34+
expect(parseSearchInput('xxorg:bar')).toBeNull();
3835
});
3936
});
4037

@@ -60,64 +57,49 @@ describe('renderer/utils/notifications/filters/search.ts', () => {
6057

6158
it('matches author qualifier (case-insensitive)', () => {
6259
expect(
63-
filterNotificationBySearchTerm(mockNotification, `author:github-user`),
60+
filterNotificationBySearchTerm(mockNotification, 'author:github-user'),
6461
).toBe(true);
6562

6663
expect(
67-
filterNotificationBySearchTerm(
68-
mockNotification,
69-
`author:GITHUB-USER`,
70-
),
64+
filterNotificationBySearchTerm(mockNotification, 'author:GITHUB-USER'),
7165
).toBe(true);
7266

7367
expect(
74-
filterNotificationBySearchTerm(
75-
mockNotification,
76-
`author:some-bot`,
77-
),
68+
filterNotificationBySearchTerm(mockNotification, 'author:some-bot'),
7869
).toBe(false);
7970
});
8071

8172
it('matches org qualifier (case-insensitive)', () => {
8273
expect(
83-
filterNotificationBySearchTerm(
84-
mockNotification,
85-
`org:gitify-app`,
86-
),
74+
filterNotificationBySearchTerm(mockNotification, 'org:gitify-app'),
8775
).toBe(true);
8876

8977
expect(
90-
filterNotificationBySearchTerm(
91-
mockNotification,
92-
`org:GITIFY-APP`,
93-
),
78+
filterNotificationBySearchTerm(mockNotification, 'org:GITIFY-APP'),
9479
).toBe(true);
9580

9681
expect(
97-
filterNotificationBySearchTerm(mockNotification, `org:github`),
82+
filterNotificationBySearchTerm(mockNotification, 'org:github'),
9883
).toBe(false);
9984
});
10085

10186
it('matches repo qualifier (case-insensitive full_name)', () => {
10287
expect(
10388
filterNotificationBySearchTerm(
10489
mockNotification,
105-
`repo:gitify-app/gitify`,
90+
'repo:gitify-app/gitify',
10691
),
10792
).toBe(true);
10893

10994
expect(
11095
filterNotificationBySearchTerm(
11196
mockNotification,
112-
`repo:Gitify-App/Gitify`,
97+
'repo:Gitify-App/Gitify',
11398
),
11499
).toBe(true);
115100

116101
expect(
117-
filterNotificationBySearchTerm(
118-
mockNotification,
119-
`repo:github/other`,
120-
),
102+
filterNotificationBySearchTerm(mockNotification, 'repo:github/other'),
121103
).toBe(false);
122104
});
123105

src/renderer/utils/notifications/filters/search.ts

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,11 @@ export const ALL_SEARCH_QUALIFIERS: readonly SearchQualifier[] = Object.values(
3333
SEARCH_QUALIFIERS,
3434
) as readonly SearchQualifier[];
3535

36+
export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] =
37+
ALL_SEARCH_QUALIFIERS.filter((q) => !q.requiresDetailsNotifications);
3638

37-
export const BASE_SEARCH_QUALIFIERS: readonly SearchQualifier[] = ALL_SEARCH_QUALIFIERS.filter(
38-
(q) => !q.requiresDetailsNotifications,
39-
);
40-
41-
export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] = ALL_SEARCH_QUALIFIERS.filter(
42-
(q) => q.requiresDetailsNotifications,
43-
);
44-
39+
export const DETAILED_ONLY_SEARCH_QUALIFIERS: readonly SearchQualifier[] =
40+
ALL_SEARCH_QUALIFIERS.filter((q) => q.requiresDetailsNotifications);
4541

4642
export function hasIncludeSearchFilters(settings: SettingsState) {
4743
return settings.filterIncludeSearchTokens.length > 0;
@@ -51,63 +47,46 @@ export function hasExcludeSearchFilters(settings: SettingsState) {
5147
return settings.filterExcludeSearchTokens.length > 0;
5248
}
5349

54-
function stripPrefix(token: string, qualifier: SearchQualifier) {
55-
return token.slice(qualifier.prefix.length).trim();
56-
}
57-
5850
export interface ParsedSearchToken {
59-
qualifier: SearchQualifier;
60-
value: string;
61-
valueLower: string;
51+
qualifier: SearchQualifier; // matched qualifier
52+
value: string; // original-case value after prefix
53+
valueLower: string; // lowercase cached
54+
token: string; // canonical stored token (prefix + value)
6255
}
6356

64-
export function parseSearchToken(token: string): ParsedSearchToken | null {
65-
if (!token) {
57+
export function parseSearchInput(raw: string): ParsedSearchToken | null {
58+
const trimmed = raw.trim();
59+
if (!trimmed) {
6660
return null;
6761
}
6862

63+
const lower = trimmed.toLowerCase();
64+
6965
for (const qualifier of ALL_SEARCH_QUALIFIERS) {
70-
if (token.startsWith(qualifier.prefix)) {
71-
const value = stripPrefix(token, qualifier);
72-
73-
if (!value) {
74-
return null; // prefix only
66+
if (lower.startsWith(qualifier.prefix)) {
67+
const valuePart = trimmed.slice(qualifier.prefix.length).trim();
68+
if (!valuePart) {
69+
return null;
7570
}
76-
77-
return { qualifier, value, valueLower: value.toLowerCase() };
71+
72+
const token = qualifier.prefix + valuePart;
73+
return {
74+
qualifier,
75+
value: valuePart,
76+
valueLower: valuePart.toLowerCase(),
77+
token,
78+
};
7879
}
7980
}
8081
return null;
8182
}
8283

83-
// Normalize raw user input from the token text field into a SearchToken (string)
84-
// Returns null if no known prefix or no value after prefix yet.
85-
export function normalizeSearchInputToToken(raw: string): string | null {
86-
const value = raw.trim();
87-
if (!value) {
88-
return null;
89-
}
90-
91-
const lower = value.toLowerCase();
92-
const matchedQualifier = ALL_SEARCH_QUALIFIERS.find((q) => lower.startsWith(q.prefix));
93-
94-
if (!matchedQualifier) {
95-
return null;
96-
}
97-
98-
const rest = value.substring(matchedQualifier.prefix.length);
99-
if (rest.length === 0) {
100-
return null; // prefix only, incomplete token
101-
}
102-
103-
return `${matchedQualifier.prefix}${rest}`;
104-
}
105-
10684
export function filterNotificationBySearchTerm(
10785
notification: Notification,
10886
token: string,
10987
): boolean {
110-
const parsed = parseSearchToken(token);
88+
const parsed = parseSearchInput(token);
89+
11190
if (!parsed) {
11291
return false;
11392
}

0 commit comments

Comments
 (0)