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
+
7
4
import {
8
5
Label ,
9
6
Menu ,
@@ -12,9 +9,18 @@ import {
12
9
MenuList ,
13
10
Popper ,
14
11
SearchInput ,
12
+ Spinner ,
15
13
} 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" ;
18
24
19
25
export interface IEntity {
20
26
id : string ;
@@ -48,66 +54,36 @@ const entityToMenu = (option: IEntity) => {
48
54
) ;
49
55
} ;
50
56
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 ) {
84
58
const params : HubRequestParams = {
85
59
filters : [
86
60
{ field : FILTER_TEXT_CATEGORY_KEY , operator : "~" , value : filterText } ,
87
61
] ,
88
- page : { pageNumber : 1 , itemsPerPage : 10 } ,
62
+ page : { pageNumber : 1 , itemsPerPage : 5 } ,
89
63
} ;
90
64
91
65
const {
66
+ isFetching : isFetchingAdvisories ,
92
67
result : { data : advisories } ,
93
- } = useFetchAdvisories ( { ...params } ) ;
68
+ } = useFetchAdvisories ( { ...params } , true , disableSearch ) ;
94
69
95
70
const {
71
+ isFetching : isFetchingPackages ,
96
72
result : { data : packages } ,
97
- } = useFetchPackages ( { ...params } ) ;
73
+ } = useFetchPackages ( { ...params } , true , disableSearch ) ;
98
74
99
75
const {
76
+ isFetching : isFetchingSBOMs ,
100
77
result : { data : sboms } ,
101
- } = useFetchSBOMs ( { ...params } ) ;
78
+ } = useFetchSBOMs ( { ...params } , true , disableSearch ) ;
102
79
103
80
const {
81
+ isFetching : isFetchingVulnerabilities ,
104
82
result : { data : vulnerabilities } ,
105
- } = useFetchVulnerabilities ( { ...params } ) ;
106
-
107
- const tmpArray : IEntity [ ] = [ ] ;
83
+ } = useFetchVulnerabilities ( { ...params } , true , disableSearch ) ;
108
84
109
85
const transformedAdvisories : IEntity [ ] = advisories . map ( ( item ) => ( {
110
- id : item . document_id ,
86
+ id : `advisory- ${ item . uuid } ` ,
111
87
title : item . document_id ,
112
88
description : item . title ?. substring ( 0 , 75 ) ,
113
89
navLink : `/advisories/${ item . uuid } ` ,
@@ -116,15 +92,15 @@ function useAllEntities(filterText: string) {
116
92
} ) ) ;
117
93
118
94
const transformedPackages : IEntity [ ] = packages . map ( ( item ) => ( {
119
- id : item . uuid ,
95
+ id : `package- ${ item . uuid } ` ,
120
96
title : item . purl ,
121
97
navLink : `/packages/${ item . uuid } ` ,
122
98
type : "Package" ,
123
99
typeColor : "cyan" ,
124
100
} ) ) ;
125
101
126
102
const transformedSboms : IEntity [ ] = sboms . map ( ( item ) => ( {
127
- id : item . id ,
103
+ id : `sbom- ${ item . id } ` ,
128
104
title : item . name ,
129
105
description : item . authors . join ( ", " ) ,
130
106
navLink : `/sboms/${ item . id } ` ,
@@ -133,24 +109,44 @@ function useAllEntities(filterText: string) {
133
109
} ) ) ;
134
110
135
111
const transformedVulnerabilities : IEntity [ ] = vulnerabilities . map ( ( item ) => ( {
136
- id : item . identifier ,
112
+ id : `vulnerability- ${ item . identifier } ` ,
137
113
title : item . identifier ,
138
114
description : item . description ?. substring ( 0 , 75 ) ,
139
115
navLink : `/vulnerabilities/${ item . identifier } ` ,
140
116
type : "Vulnerability" ,
141
117
typeColor : "orange" ,
142
118
} ) ) ;
143
119
144
- tmpArray . push (
120
+ const filterTextLowerCase = filterText . toLowerCase ( ) ;
121
+
122
+ const list = [
123
+ ...transformedVulnerabilities ,
124
+ ...transformedSboms ,
145
125
...transformedAdvisories ,
146
126
...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
+ } ) ;
150
142
151
143
return {
152
- list : tmpArray ,
153
- defaultValue : "" ,
144
+ isFetching :
145
+ isFetchingAdvisories ||
146
+ isFetchingPackages ||
147
+ isFetchingSBOMs ||
148
+ isFetchingVulnerabilities ,
149
+ list,
154
150
} ;
155
151
}
156
152
@@ -162,18 +158,34 @@ export interface ISearchMenu {
162
158
onChangeSearch : ( searchValue : string | undefined ) => void ;
163
159
}
164
160
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
+ ) ;
170
178
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
173
187
) ;
174
- const [ autocompleteOptions , setAutocompleteOptions ] = React . useState <
175
- React . JSX . Element [ ]
176
- > ( [ ] ) ;
188
+
177
189
const [ isAutocompleteOpen , setIsAutocompleteOpen ] =
178
190
React . useState < boolean > ( false ) ;
179
191
@@ -188,17 +200,12 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
188
200
searchInputRef . current . contains ( document . activeElement )
189
201
) {
190
202
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 ) ;
197
203
} else {
198
204
setIsAutocompleteOpen ( false ) ;
199
205
}
200
206
201
207
setSearchValue ( newValue ) ;
208
+ setIsSearchValueDirty ( true ) ;
202
209
} ;
203
210
204
211
const onClearSearchValue = ( ) => {
@@ -273,9 +280,26 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
273
280
} , [ isAutocompleteOpen ] ) ;
274
281
275
282
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
+ >
277
293
< 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 >
279
303
</ MenuContent >
280
304
</ Menu >
281
305
) ;
@@ -301,7 +325,7 @@ export const SearchMenu: React.FC<ISearchMenu> = ({
301
325
triggerRef = { searchInputRef }
302
326
popper = { autocomplete }
303
327
popperRef = { autocompleteRef }
304
- isVisible = { isAutocompleteOpen }
328
+ isVisible = { ( isAutocompleteOpen && entityList . length > 0 ) || isFetching }
305
329
enableFlip = { false }
306
330
// append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
307
331
appendTo = { ( ) =>
0 commit comments