@@ -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
1320import { AppContext } from '../../context/App' ;
1421import { IconColor , type SearchToken , Size } from '../../types' ;
1522import {
1623 hasExcludeSearchFilters ,
1724 hasIncludeSearchFilters ,
25+ SEARCH_PREFIXES ,
1826} from '../../utils/notifications/filters/search' ;
1927import { Tooltip } from '../fields/Tooltip' ;
2028import { Title } from '../primitives/Title' ;
2129import { 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+
2857const tokenEvents = [ 'Enter' , 'Tab' , ' ' , ',' ] ;
2958
3059function 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
4072export 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