1
- import { useEffect , useMemo , useState } from 'react' ;
2
- import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types' ;
1
+ import { ChangeEvent , useCallback , useEffect , useMemo , useState } from 'react' ;
2
+ import { parseAsJson , useQueryState } from 'nuqs' ;
3
+ import objectHash from 'object-hash' ;
4
+ import {
5
+ ChartConfigWithDateRange ,
6
+ Filter ,
7
+ } from '@hyperdx/common-utils/dist/types' ;
3
8
import {
4
9
Box ,
5
10
Button ,
@@ -21,7 +26,7 @@ import { IconSearch } from '@tabler/icons-react';
21
26
import { useAllFields , useGetKeyValues } from '@/hooks/useMetadata' ;
22
27
import useResizable from '@/hooks/useResizable' ;
23
28
import { useSearchPageFilterState } from '@/searchFilters' ;
24
- import { mergePath } from '@/utils' ;
29
+ import { mergePath , useLocalStorage } from '@/utils' ;
25
30
26
31
import resizeStyles from '../../styles/ResizablePanel.module.scss' ;
27
32
import classes from '../../styles/SearchPage.module.scss' ;
@@ -304,6 +309,149 @@ export const FilterGroup = ({
304
309
) ;
305
310
} ;
306
311
312
+ type SavedFilters = {
313
+ [ key : string ] : Filter [ ] ;
314
+ } ;
315
+
316
+ function SaveFilterInput ( ) {
317
+ const [ savedFilters , setSavedFilters ] = useLocalStorage < SavedFilters > (
318
+ 'hdx-saved-search-filters' ,
319
+ { } ,
320
+ ) ;
321
+ const [ queryFilters ] = useQueryState < Filter [ ] > (
322
+ 'filters' ,
323
+ parseAsJson < Filter [ ] > ( ) ,
324
+ ) ;
325
+ const [ newFilterName , setNewFilterName ] = useState ( '' ) ;
326
+ const [ showButton , setShowButton ] = useState ( true ) ;
327
+ const handleChange = ( e : ChangeEvent < HTMLInputElement > ) => {
328
+ e . preventDefault ( ) ;
329
+ setNewFilterName ( e . target . value ) ;
330
+ } ;
331
+ const handleSubmit = ( e : React . FormEvent ) => {
332
+ e . preventDefault ( ) ;
333
+ if ( ! queryFilters ) return ;
334
+ const tmp = savedFilters ;
335
+ tmp [ newFilterName ] = queryFilters ;
336
+ setSavedFilters ( tmp ) ;
337
+ } ;
338
+
339
+ return (
340
+ < Flex pl = "xs" py = "xxs" mb = "xs" className = { classes . filterCheckbox } >
341
+ { showButton ? (
342
+ < UnstyledButton
343
+ onClick = { ( ) => setShowButton ( false ) }
344
+ className = { classes . textButton }
345
+ style = { { width : '100%' } }
346
+ >
347
+ < Text size = "xs" c = "gray.6" lh = { 1 } >
348
+ < b > + Save Filter</ b >
349
+ </ Text >
350
+ </ UnstyledButton >
351
+ ) : (
352
+ < form onSubmit = { handleSubmit } >
353
+ < TextInput
354
+ autoFocus
355
+ onBlur = { ( ) => setShowButton ( true ) }
356
+ placeholder = "New Filter"
357
+ onChange = { handleChange }
358
+ name = "newFilterName"
359
+ />
360
+ </ form >
361
+ ) }
362
+ </ Flex >
363
+ ) ;
364
+ }
365
+
366
+ export function SavedFilters ( ) {
367
+ const [ queryFilters , setQueryFilters ] = useQueryState (
368
+ 'filters' ,
369
+ parseAsJson < Filter [ ] > ( ) ,
370
+ ) ;
371
+ const [ savedFilters , setSavedFilters ] = useLocalStorage < SavedFilters > (
372
+ 'hdx-saved-search-filters' ,
373
+ { } ,
374
+ ) ;
375
+ const showSaveButton = useMemo (
376
+ // true if no saved filter matches the current filters
377
+ ( ) =>
378
+ queryFilters &&
379
+ queryFilters . length > 0 &&
380
+ ! Object . entries ( savedFilters ) . some (
381
+ ( [ _ , filter ] ) =>
382
+ objectHash . sha1 ( filter ) === objectHash . sha1 ( queryFilters ) ,
383
+ ) ,
384
+ [ queryFilters , savedFilters ] ,
385
+ ) ;
386
+ const removeFilter = useCallback (
387
+ ( label : string ) => {
388
+ const newFilters = structuredClone ( savedFilters ) ;
389
+ delete newFilters [ label ] ;
390
+ setSavedFilters ( newFilters ) ;
391
+ } ,
392
+ [ savedFilters , setSavedFilters ] ,
393
+ ) ;
394
+
395
+ const SavedFilterOption = ( {
396
+ label,
397
+ filters,
398
+ } : {
399
+ label : string ;
400
+ filters : Filter [ ] ;
401
+ } ) => {
402
+ const [ isHovered , setIsHovered ] = useState ( false ) ;
403
+ const active = objectHash . sha1 ( filters ) === objectHash . sha1 ( queryFilters ) ;
404
+ return (
405
+ < Group
406
+ key = { label }
407
+ justify = "space-between"
408
+ wrap = "nowrap"
409
+ onMouseOver = { ( ) => setIsHovered ( true ) }
410
+ onMouseOut = { ( ) => setIsHovered ( false ) }
411
+ className = { classes . highlightRow }
412
+ >
413
+ < Text
414
+ size = "xs"
415
+ c = { active ? 'green' : 'gray.3' }
416
+ w = "100%"
417
+ pl = "xs"
418
+ onClick = { ( ) => setQueryFilters ( filters ) }
419
+ style = { { cursor : 'pointer' , opacity : 0.8 } }
420
+ >
421
+ { label }
422
+ </ Text >
423
+ { /* ONLY SHOW X IF HOVERING OVER THIS COMPONENT */ }
424
+ < UnstyledButton
425
+ className = { classes . highlightButton }
426
+ style = { { visibility : isHovered ? 'inherit' : 'hidden' } }
427
+ p = "2px"
428
+ onClick = { ( ) => removeFilter ( label ) }
429
+ >
430
+ < i className = "bi bi-x" > </ i >
431
+ </ UnstyledButton >
432
+ </ Group >
433
+ ) ;
434
+ } ;
435
+
436
+ return (
437
+ < Stack gap = { 0 } >
438
+ { ( Object . keys ( savedFilters ) . length > 0 || showSaveButton ) && (
439
+ < Text size = "xxs" c = "dimmed" fw = "bold" >
440
+ Saved Filters
441
+ </ Text >
442
+ ) }
443
+ { Object . keys ( savedFilters ) . length > 0 && (
444
+ < Stack gap = { 0 } >
445
+ { Object . entries ( savedFilters ) . map ( ( [ label , filters ] ) => (
446
+ < SavedFilterOption key = { label } label = { label } filters = { filters } />
447
+ ) ) }
448
+ </ Stack >
449
+ ) }
450
+ { showSaveButton && < SaveFilterInput /> }
451
+ </ Stack >
452
+ ) ;
453
+ }
454
+
307
455
type FilterStateHook = ReturnType < typeof useSearchPageFilterState > ;
308
456
309
457
export const DBSearchPageFilters = ( {
@@ -443,6 +591,8 @@ export const DBSearchPageFilters = ({
443
591
</ Tabs . List >
444
592
</ Tabs >
445
593
594
+ < SavedFilters />
595
+
446
596
< Flex align = "center" justify = "space-between" >
447
597
< Flex className = { isFacetsFetching ? 'effect-pulse' : '' } >
448
598
< Text size = "xxs" c = "dimmed" fw = "bold" >
0 commit comments