1- import { HubRequestParams } from "@app/api/models" ;
2- import { FILTER_TEXT_CATEGORY_KEY } from "@app/Constants" ;
3- import { useFetchAdvisories } from "@app/queries/advisories" ;
4- import { useFetchPackages } from "@app/queries/packages" ;
5- import { useFetchSBOMs } from "@app/queries/sboms" ;
6- import { useFetchVulnerabilities } from "@app/queries/vulnerabilities" ;
1+ import React from "react" ;
2+ import { Link } from "react-router-dom" ;
3+
74import {
85 Label ,
96 Menu ,
@@ -12,9 +9,18 @@ import {
129 MenuList ,
1310 Popper ,
1411 SearchInput ,
12+ Spinner ,
1513} from "@patternfly/react-core" ;
16- import React from "react" ;
17- import { Link } from "react-router-dom" ;
14+
15+ import { useDebounceValue } from "usehooks-ts" ;
16+
17+ import { HubRequestParams } from "@app/api/models" ;
18+ import { FILTER_TEXT_CATEGORY_KEY } from "@app/Constants" ;
19+ import { SbomSearchContext } from "@app/pages/sbom-list/sbom-context" ;
20+ import { useFetchAdvisories } from "@app/queries/advisories" ;
21+ import { useFetchPackages } from "@app/queries/packages" ;
22+ import { useFetchSBOMs } from "@app/queries/sboms" ;
23+ import { useFetchVulnerabilities } from "@app/queries/vulnerabilities" ;
1824
1925export interface IEntity {
2026 id : string ;
@@ -48,66 +54,36 @@ const entityToMenu = (option: IEntity) => {
4854 ) ;
4955} ;
5056
51- // Filter function
52- export function filterEntityListByValue ( list : IEntity [ ] , searchString : string ) {
53- // When the value of the search input changes, build a list of no more than 10 autocomplete options.
54- // Options which start with the search input value are listed first, followed by options which contain
55- // the search input value.
56- let options : React . JSX . Element [ ] = list
57- . filter (
58- ( option ) =>
59- option . id . toLowerCase ( ) . startsWith ( searchString . toLowerCase ( ) ) ||
60- option . title ?. toLowerCase ( ) . startsWith ( searchString . toLowerCase ( ) ) ||
61- option . description ?. toLowerCase ( ) . startsWith ( searchString . toLowerCase ( ) )
62- )
63- . map ( entityToMenu ) ;
64-
65- if ( options . length > 10 ) {
66- options = options . slice ( 0 , 10 ) ;
67- } else {
68- options = [
69- ...options ,
70- ...list
71- . filter (
72- ( option : IEntity ) =>
73- ! option . id . startsWith ( searchString . toLowerCase ( ) ) &&
74- option . id . includes ( searchString . toLowerCase ( ) )
75- )
76- . map ( entityToMenu ) ,
77- ] . slice ( 0 , 10 ) ;
78- }
79-
80- return options ;
81- }
82-
83- function useAllEntities ( filterText : string ) {
57+ function useAllEntities ( filterText : string , disableSearch : boolean ) {
8458 const params : HubRequestParams = {
8559 filters : [
8660 { field : FILTER_TEXT_CATEGORY_KEY , operator : "~" , value : filterText } ,
8761 ] ,
88- page : { pageNumber : 1 , itemsPerPage : 10 } ,
62+ page : { pageNumber : 1 , itemsPerPage : 5 } ,
8963 } ;
9064
9165 const {
66+ isFetching : isFetchingAdvisories ,
9267 result : { data : advisories } ,
93- } = useFetchAdvisories ( { ...params } ) ;
68+ } = useFetchAdvisories ( { ...params } , true , disableSearch ) ;
9469
9570 const {
71+ isFetching : isFetchingPackages ,
9672 result : { data : packages } ,
97- } = useFetchPackages ( { ...params } ) ;
73+ } = useFetchPackages ( { ...params } , true , disableSearch ) ;
9874
9975 const {
76+ isFetching : isFetchingSBOMs ,
10077 result : { data : sboms } ,
101- } = useFetchSBOMs ( { ...params } ) ;
78+ } = useFetchSBOMs ( { ...params } , true , disableSearch ) ;
10279
10380 const {
81+ isFetching : isFetchingVulnerabilities ,
10482 result : { data : vulnerabilities } ,
105- } = useFetchVulnerabilities ( { ...params } ) ;
106-
107- const tmpArray : IEntity [ ] = [ ] ;
83+ } = useFetchVulnerabilities ( { ...params } , true , disableSearch ) ;
10884
10985 const transformedAdvisories : IEntity [ ] = advisories . map ( ( item ) => ( {
110- id : item . document_id ,
86+ id : `advisory- ${ item . uuid } ` ,
11187 title : item . document_id ,
11288 description : item . title ?. substring ( 0 , 75 ) ,
11389 navLink : `/advisories/${ item . uuid } ` ,
@@ -116,15 +92,15 @@ function useAllEntities(filterText: string) {
11692 } ) ) ;
11793
11894 const transformedPackages : IEntity [ ] = packages . map ( ( item ) => ( {
119- id : item . uuid ,
95+ id : `package- ${ item . uuid } ` ,
12096 title : item . purl ,
12197 navLink : `/packages/${ item . uuid } ` ,
12298 type : "Package" ,
12399 typeColor : "cyan" ,
124100 } ) ) ;
125101
126102 const transformedSboms : IEntity [ ] = sboms . map ( ( item ) => ( {
127- id : item . id ,
103+ id : `sbom- ${ item . id } ` ,
128104 title : item . name ,
129105 description : item . authors . join ( ", " ) ,
130106 navLink : `/sboms/${ item . id } ` ,
@@ -133,24 +109,44 @@ function useAllEntities(filterText: string) {
133109 } ) ) ;
134110
135111 const transformedVulnerabilities : IEntity [ ] = vulnerabilities . map ( ( item ) => ( {
136- id : item . identifier ,
112+ id : `vulnerability- ${ item . identifier } ` ,
137113 title : item . identifier ,
138114 description : item . description ?. substring ( 0 , 75 ) ,
139115 navLink : `/vulnerabilities/${ item . identifier } ` ,
140116 type : "Vulnerability" ,
141117 typeColor : "orange" ,
142118 } ) ) ;
143119
144- tmpArray . push (
120+ const filterTextLowerCase = filterText . toLowerCase ( ) ;
121+
122+ const list = [
123+ ...transformedVulnerabilities ,
124+ ...transformedSboms ,
145125 ...transformedAdvisories ,
146126 ...transformedPackages ,
147- ...transformedSboms ,
148- ...transformedVulnerabilities
149- ) ;
127+ ] . sort ( ( a , b ) => {
128+ if ( a . title ?. includes ( filterTextLowerCase ) ) {
129+ return - 1 ;
130+ } else if ( b . title ?. includes ( filterTextLowerCase ) ) {
131+ return 1 ;
132+ } else {
133+ const aIndex = ( a . description || "" )
134+ . toLowerCase ( )
135+ . indexOf ( filterTextLowerCase ) ;
136+ const bIndex = ( b . description || "" )
137+ . toLowerCase ( )
138+ . indexOf ( filterTextLowerCase ) ;
139+ return aIndex - bIndex ;
140+ }
141+ } ) ;
150142
151143 return {
152- list : tmpArray ,
153- defaultValue : "" ,
144+ isFetching :
145+ isFetchingAdvisories ||
146+ isFetchingPackages ||
147+ isFetchingSBOMs ||
148+ isFetchingVulnerabilities ,
149+ list,
154150 } ;
155151}
156152
@@ -162,18 +158,34 @@ export interface ISearchMenu {
162158 onChangeSearch : ( searchValue : string | undefined ) => void ;
163159}
164160
165- export const SearchMenu : React . FC < ISearchMenu > = ( {
166- filterFunction = filterEntityListByValue ,
167- onChangeSearch,
168- } ) => {
169- const { list : entityList , defaultValue } = useAllEntities ( "" ) ;
161+ export const SearchMenu : React . FC < ISearchMenu > = ( { onChangeSearch } ) => {
162+ // Search value initial value
163+ const { tableControls : sbomTableControls } =
164+ React . useContext ( SbomSearchContext ) ;
165+ const initialSearchValue =
166+ sbomTableControls . filterState . filterValues [ FILTER_TEXT_CATEGORY_KEY ] ?. [ 0 ] ||
167+ "" ;
168+
169+ // Search value
170+ const [ searchValue , setSearchValue ] = React . useState ( initialSearchValue ) ;
171+ const [ isSearchValueDirty , setIsSearchValueDirty ] = React . useState ( false ) ;
172+
173+ // Debounce Search value
174+ const [ debouncedSearchValue , setDebouncedSearchValue ] = useDebounceValue (
175+ searchValue ,
176+ 500
177+ ) ;
170178
171- const [ searchValue , setSearchValue ] = React . useState < string | undefined > (
172- defaultValue
179+ React . useEffect ( ( ) => {
180+ setDebouncedSearchValue ( searchValue ) ;
181+ } , [ setDebouncedSearchValue , searchValue ] ) ;
182+
183+ // Fetch all entities
184+ const { isFetching, list : entityList } = useAllEntities (
185+ debouncedSearchValue ,
186+ ! isSearchValueDirty
173187 ) ;
174- const [ autocompleteOptions , setAutocompleteOptions ] = React . useState <
175- React . JSX . Element [ ]
176- > ( [ ] ) ;
188+
177189 const [ isAutocompleteOpen , setIsAutocompleteOpen ] =
178190 React . useState < boolean > ( false ) ;
179191
@@ -188,17 +200,12 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
188200 searchInputRef . current . contains ( document . activeElement )
189201 ) {
190202 setIsAutocompleteOpen ( true ) ;
191-
192- const options = filterFunction ( entityList , newValue ) ;
193-
194- // The menu is hidden if there are no options
195- setIsAutocompleteOpen ( options . length > 0 ) ;
196- setAutocompleteOptions ( options ) ;
197203 } else {
198204 setIsAutocompleteOpen ( false ) ;
199205 }
200206
201207 setSearchValue ( newValue ) ;
208+ setIsSearchValueDirty ( true ) ;
202209 } ;
203210
204211 const onClearSearchValue = ( ) => {
@@ -273,9 +280,26 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
273280 } , [ isAutocompleteOpen ] ) ;
274281
275282 const autocomplete = (
276- < Menu ref = { autocompleteRef } style = { { maxWidth : "450px" } } >
283+ < Menu
284+ ref = { autocompleteRef }
285+ style = { {
286+ maxWidth : "450px" ,
287+ maxHeight : "450px" ,
288+ overflow : "scroll" ,
289+ overflowX : "hidden" ,
290+ overflowY : "auto" ,
291+ } }
292+ >
277293 < MenuContent >
278- < MenuList > { autocompleteOptions } </ MenuList >
294+ < MenuList >
295+ { isFetching ? (
296+ < MenuItem itemId = "loading" >
297+ < Spinner size = "sm" />
298+ </ MenuItem >
299+ ) : (
300+ entityList . map ( entityToMenu )
301+ ) }
302+ </ MenuList >
279303 </ MenuContent >
280304 </ Menu >
281305 ) ;
@@ -301,7 +325,7 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
301325 triggerRef = { searchInputRef }
302326 popper = { autocomplete }
303327 popperRef = { autocompleteRef }
304- isVisible = { isAutocompleteOpen }
328+ isVisible = { ( isAutocompleteOpen && entityList . length > 0 ) || isFetching }
305329 enableFlip = { false }
306330 // append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
307331 appendTo = { ( ) =>
0 commit comments