11import type { FC , FormEvent , KeyboardEvent , Ref } from 'react' ;
2- import { useState , useRef , useEffect , Fragment } from 'react' ;
2+ import { useState , useRef , useEffect , useMemo , Fragment } from 'react' ;
33import * as _ from 'lodash' ;
44import { connect } from 'react-redux' ;
55import { Map as ImmutableMap , Set as ImmutableSet } from 'immutable' ;
@@ -28,6 +28,7 @@ import {
2828import { TimesIcon } from '@patternfly/react-icons' ;
2929
3030const RECENT_SEARCH_ITEMS = 5 ;
31+ const MAX_VISIBLE_ITEMS = 100 ;
3132
3233// Blacklist known duplicate resources.
3334const blacklistGroups = ImmutableSet ( [
@@ -44,59 +45,74 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
4445 const { selected, onChange, recentList, allModels, groupToVersionMap, className } = props ;
4546 const { t } = useTranslation ( ) ;
4647 const [ isOpen , setIsOpen ] = useState ( false ) ;
47- const [ clearItems , setClearItems ] = useState ( false ) ;
4848 const [ recentSelected , setRecentSelected ] = useUserPreference < string > (
4949 'console.search.recentlySearched' ,
5050 '[]' ,
5151 true ,
5252 ) ;
5353 const [ selectedOptions , setSelectedOptions ] = useState ( selected ) ;
54- const [ initialSelectOptions , setInitialSelectOptions ] = useState < ExtendedSelectOptionProps [ ] > ( [ ] ) ;
55- const [ selectOptions , setSelectOptions ] = useState < ExtendedSelectOptionProps [ ] > (
56- initialSelectOptions ,
57- ) ;
5854 const [ inputValue , setInputValue ] = useState < string > ( '' ) ;
5955 const [ focusedItemIndex , setFocusedItemIndex ] = useState < number | null > ( null ) ;
6056 const [ activeItemId , setActiveItemId ] = useState < string | null > ( null ) ;
6157 const placeholderTextDefault = t ( 'public~Resources' ) ;
6258 const [ placeholder , setPlaceholder ] = useState ( placeholderTextDefault ) ;
6359 const textInputRef = useRef < HTMLInputElement > ( ) ;
6460
65- const resources = allModels
66- . filter ( ( { apiGroup, apiVersion, kind, verbs } ) => {
67- // Remove blacklisted items.
68- if (
69- blacklistGroups . has ( apiGroup ) ||
70- blacklistResources . has ( `${ apiGroup } /${ apiVersion } .${ kind } ` )
71- ) {
72- return false ;
73- }
74-
75- // Only show resources that can be listed.
76- if ( ! _ . isEmpty ( verbs ) && ! _ . includes ( verbs , 'list' ) ) {
77- return false ;
61+ const resources = useMemo ( ( ) => {
62+ // Pre-compute which group+kind combinations have a preferred version (O(n))
63+ const preferredGroupKinds = new Set < string > ( ) ;
64+ allModels . forEach ( ( m ) => {
65+ if ( groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion ) {
66+ preferredGroupKinds . add ( `${ m . kind } ~${ m . apiGroup } ` ) ;
7867 }
68+ } ) ;
7969
80- // Only show preferred version for resources in the same API group.
81- const preferred = ( m : K8sKind ) =>
82- groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion ;
70+ return allModels
71+ . filter ( ( { apiGroup, apiVersion, kind, verbs } ) => {
72+ // Remove blacklisted items.
73+ if (
74+ blacklistGroups . has ( apiGroup ) ||
75+ blacklistResources . has ( `${ apiGroup } /${ apiVersion } .${ kind } ` )
76+ ) {
77+ return false ;
78+ }
8379
84- const sameGroupKind = ( m : K8sKind ) =>
85- m . kind === kind && m . apiGroup === apiGroup && m . apiVersion !== apiVersion ;
80+ // Only show resources that can be listed.
81+ if ( ! _ . isEmpty ( verbs ) && ! _ . includes ( verbs , 'list' ) ) {
82+ return false ;
83+ }
8684
87- return ! allModels . find ( ( m ) => sameGroupKind ( m ) && preferred ( m ) ) ;
88- } )
89- . toOrderedMap ( )
90- . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
85+ // Only show preferred version for resources in the same API group.
86+ // If a preferred version exists for this group+kind and this isn't it, skip.
87+ const key = `${ kind } ~${ apiGroup } ` ;
88+ if (
89+ preferredGroupKinds . has ( key ) &&
90+ groupToVersionMap ?. [ apiGroup ] ?. preferredVersion !== apiVersion
91+ ) {
92+ return false ;
93+ }
9194
92- useEffect ( ( ) => {
93- const resourcesToOption : SelectOptionProps [ ] = resources . toArray ( ) . map ( ( resource ) => {
94- const reference = referenceForModel ( resource ) ;
95- return { value : reference , children : reference , shortNames : resource . shortNames } ;
96- } ) ;
97- setInitialSelectOptions ( resourcesToOption ) ;
98- // eslint-disable-next-line react-hooks/exhaustive-deps
99- } , [ ] ) ;
95+ return true ;
96+ } )
97+ . toOrderedMap ( )
98+ . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
99+ } , [ allModels , groupToVersionMap ] ) ;
100+
101+ const initialSelectOptions = useMemo < ExtendedSelectOptionProps [ ] > (
102+ ( ) =>
103+ resources . toArray ( ) . map ( ( resource ) => {
104+ const reference = referenceForModel ( resource ) ;
105+ return {
106+ value : reference ,
107+ children : reference ,
108+ shortNames : resource . shortNames ,
109+ // Pre-compute lowercase for filtering so we don't repeat it on every keystroke
110+ searchableText : reference . toLowerCase ( ) ,
111+ searchableShortNames : resource . shortNames ?. map ( ( s ) => s . toLowerCase ( ) ) ,
112+ } ;
113+ } ) ,
114+ [ resources ] ,
115+ ) ;
100116
101117 const filterGroupVersionKind = ( resourceList : string [ ] ) : string [ ] => {
102118 return resourceList . filter ( ( resource ) => {
@@ -116,40 +132,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
116132
117133 useEffect ( ( ) => {
118134 setSelectedOptions ( selected ) ;
119- ! _ . isEmpty ( selected ) &&
120- setRecentSelected (
121- JSON . stringify (
122- _ . union (
123- ! clearItems ? filterGroupVersionKind ( selected . reverse ( ) ) : [ ] ,
124- recentSelectedList ( recentSelected ) ,
125- ) ,
126- ) ,
127- ) ;
128- setClearItems ( false ) ;
129- // eslint-disable-next-line react-hooks/exhaustive-deps
130- } , [ selected , setRecentSelected ] ) ;
131-
132- useEffect ( ( ) => {
133- let newSelectOptions : SelectOptionProps [ ] = initialSelectOptions ;
134-
135- // Filter menu items based on the text input value when one exists
136- if ( inputValue ) {
137- newSelectOptions = initialSelectOptions . filter (
138- ( menuItem ) =>
139- String ( menuItem . children ) . toLowerCase ( ) . includes ( inputValue . toLowerCase ( ) ) ||
140- menuItem . shortNames ?. some ( ( shortName ) =>
141- shortName . toLowerCase ( ) . includes ( inputValue . toLowerCase ( ) ) ,
142- ) ,
143- ) ;
135+ } , [ selected ] ) ;
144136
145- // Open the menu when the input value changes and the new value is not empty
146- if ( ! isOpen ) {
147- setIsOpen ( true ) ;
148- }
137+ const selectOptions = useMemo ( ( ) => {
138+ if ( ! inputValue ) {
139+ return initialSelectOptions ;
149140 }
150-
151- setSelectOptions ( newSelectOptions ) ;
152- // eslint-disable-next-line react-hooks/exhaustive-deps
141+ const lower = inputValue . toLowerCase ( ) ;
142+ return initialSelectOptions . filter (
143+ ( menuItem ) =>
144+ menuItem . searchableText ?. includes ( lower ) ||
145+ menuItem . searchableShortNames ?. some ( ( shortName ) => shortName . includes ( lower ) ) ,
146+ ) ;
153147 } , [ inputValue , initialSelectOptions ] ) ;
154148
155149 useEffect ( ( ) => {
@@ -161,27 +155,38 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161155 } , [ placeholderTextDefault , selectedOptions , t ] ) ;
162156
163157 const createItemId = ( value : any ) => `resource-dropdown-${ value . replace ( ' ' , '-' ) } ` ;
158+
159+ // Pre-compute a reference-to-model lookup map (O(1) per lookup instead of O(n))
160+ const referenceToModelMap = useMemo ( ( ) => {
161+ const map = new Map < string , K8sKind > ( ) ;
162+ resources . forEach ( ( r ) => map . set ( referenceForModel ( r ) , r ) ) ;
163+ return map ;
164+ } , [ resources ] ) ;
165+
164166 // Track duplicate names so we know when to show the group.
165- const kinds = resources . groupBy ( ( m ) => m . kind ) ;
167+ const kinds = useMemo ( ( ) => resources . groupBy ( ( m ) => m . kind ) , [ resources ] ) ;
166168 const isDup = ( kind ) => kinds . get ( kind ) . size > 1 ;
167169
168- const items = selectOptions . map ( ( option : SelectOptionProps , index ) => {
169- const model = resources . toArray ( ) . find ( ( resource : K8sKind ) => {
170- return option . value === referenceForModel ( resource ) ;
171- } ) ;
170+ const visibleSelectOptions = selectOptions . slice ( 0 , MAX_VISIBLE_ITEMS ) ;
171+ const items = visibleSelectOptions . map ( ( option : SelectOptionProps , index ) => {
172+ const ref = option . value as string ;
173+ const model = referenceToModelMap . get ( ref ) ;
174+ if ( ! model ) {
175+ return null ;
176+ }
172177
173178 return (
174179 < SelectOption
175- key = { referenceForModel ( model ) }
176- value = { referenceForModel ( model ) }
180+ key = { ref }
181+ value = { ref }
177182 hasCheckbox
178- isSelected = { selected . includes ( referenceForModel ( model ) ) }
183+ isSelected = { selected . includes ( ref ) }
179184 isFocused = { focusedItemIndex === index }
180- id = { createItemId ( referenceForModel ( model ) ) }
185+ id = { createItemId ( ref ) }
181186 >
182187 < span className = "co-resource-item" >
183188 < span className = "co-resource-icon--fixed-width" >
184- < ResourceIcon kind = { referenceForModel ( model ) } />
189+ < ResourceIcon kind = { ref } />
185190 </ span >
186191 < span className = "co-resource-item__resource-name" >
187192 < span >
@@ -206,7 +211,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206211 const recentSearches : JSX . Element [ ] =
207212 ! _ . isEmpty ( recentSelectedList ( recentSelected ) ) &&
208213 recentSelectedList ( recentSelected )
209- . splice ( 0 , RECENT_SEARCH_ITEMS )
214+ . slice ( 0 , RECENT_SEARCH_ITEMS )
210215 . map ( ( modelRef : K8sResourceKindReference ) => {
211216 const model : K8sKind = resources . find ( ( m ) => referenceForModel ( m ) === modelRef ) ;
212217 if ( model ) {
@@ -245,7 +250,6 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
245250 . filter ( ( item ) => item !== null ) ;
246251
247252 const onClear = ( ) => {
248- setClearItems ( true ) ;
249253 setRecentSelected ( JSON . stringify ( [ ] ) ) ;
250254 } ;
251255 const NO_RESULTS = 'no results' ;
@@ -273,10 +277,11 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273277 ) ;
274278 options . push ( < Divider key = { 3 } className = "co-select-group-divider" /> ) ;
275279 }
280+ const cleanItems = items . filter ( Boolean ) ;
276281 options . push (
277282 < Fragment key = "resource-items" >
278- { items . length > 0
279- ? items
283+ { cleanItems . length > 0
284+ ? cleanItems
280285 : [
281286 < SelectOption
282287 value = { NO_RESULTS }
@@ -286,6 +291,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286291 { t ( 'public~No results found' ) }
287292 </ SelectOption > ,
288293 ] }
294+ { selectOptions . length > MAX_VISIBLE_ITEMS && (
295+ < SelectOption
296+ value = "type-to-filter"
297+ key = "select-multi-typeahead-type-to-filter"
298+ isAriaDisabled = { true }
299+ >
300+ { t ( 'public~Showing {{visible}} of {{total}} resources. Type to filter.' , {
301+ visible : MAX_VISIBLE_ITEMS ,
302+ total : selectOptions . length ,
303+ } ) }
304+ </ SelectOption >
305+ ) }
289306 </ Fragment > ,
290307 ) ;
291308 return options ;
@@ -318,36 +335,51 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
318335 const onTextInputChange = ( _event : FormEvent < HTMLInputElement > , value : string ) => {
319336 setInputValue ( value ) ;
320337 resetActiveAndFocusedItem ( ) ;
338+ if ( value && ! isOpen ) {
339+ setIsOpen ( true ) ;
340+ }
321341 } ;
322342
323343 const onSelect = ( value : string ) => {
324344 if ( value && value !== NO_RESULTS ) {
325- setSelectedOptions (
326- selected . includes ( value )
327- ? selected . filter ( ( selection ) => selection !== value )
328- : [ ...selected , value ] ,
329- ) ;
345+ const newSelected = selected . includes ( value )
346+ ? selected . filter ( ( selection ) => selection !== value )
347+ : [ ...selected , value ] ;
348+ setSelectedOptions ( newSelected ) ;
330349 onChange ( value ) ;
350+
351+ // Update recently used resources
352+ if ( ! _ . isEmpty ( newSelected ) ) {
353+ setRecentSelected (
354+ JSON . stringify (
355+ _ . union (
356+ filterGroupVersionKind ( [ ...newSelected ] . reverse ( ) ) ,
357+ recentSelectedList ( recentSelected ) ,
358+ ) ,
359+ ) ,
360+ ) ;
361+ }
331362 }
332363
333364 textInputRef . current ?. focus ( ) ;
334365 } ;
335366
336367 const handleMenuArrowKeys = ( key : string ) => {
337368 let indexToFocus = 0 ;
369+ const maxIndex = Math . min ( selectOptions . length , MAX_VISIBLE_ITEMS ) - 1 ;
338370
339371 if ( ! isOpen ) {
340372 setIsOpen ( true ) ;
341373 }
342374
343- if ( selectOptions . every ( ( option ) => option . isDisabled ) ) {
375+ if ( maxIndex < 0 || selectOptions . every ( ( option ) => option . isDisabled ) ) {
344376 return ;
345377 }
346378
347379 if ( key === 'ArrowUp' ) {
348380 // When no index is set or at the first index, focus to the last, otherwise decrement focus index
349381 if ( focusedItemIndex === null || focusedItemIndex === 0 ) {
350- indexToFocus = selectOptions . length - 1 ;
382+ indexToFocus = maxIndex ;
351383 } else {
352384 indexToFocus = focusedItemIndex - 1 ;
353385 }
@@ -356,14 +388,14 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
356388 while ( selectOptions [ indexToFocus ] . isDisabled ) {
357389 indexToFocus -- ;
358390 if ( indexToFocus === - 1 ) {
359- indexToFocus = selectOptions . length - 1 ;
391+ indexToFocus = maxIndex ;
360392 }
361393 }
362394 }
363395
364396 if ( key === 'ArrowDown' ) {
365397 // When no index is set or at the last index, focus to the first, otherwise increment focus index
366- if ( focusedItemIndex === null || focusedItemIndex === selectOptions . length - 1 ) {
398+ if ( focusedItemIndex === null || focusedItemIndex === maxIndex ) {
367399 indexToFocus = 0 ;
368400 } else {
369401 indexToFocus = focusedItemIndex + 1 ;
@@ -372,7 +404,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
372404 // Skip disabled options
373405 while ( selectOptions [ indexToFocus ] . isDisabled ) {
374406 indexToFocus ++ ;
375- if ( indexToFocus === selectOptions . length ) {
407+ if ( indexToFocus > maxIndex ) {
376408 indexToFocus = 0 ;
377409 }
378410 }
@@ -479,6 +511,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479511interface ExtendedSelectOptionProps extends SelectOptionProps {
480512 /** Searchable short names for the select options */
481513 shortNames ?: string [ ] ;
514+ /** Pre-computed lowercase text for filtering */
515+ searchableText ?: string ;
516+ /** Pre-computed lowercase short names for filtering */
517+ searchableShortNames ?: string [ ] ;
482518}
483519
484520const resourceListDropdownStateToProps = ( { k8s } ) => ( {
0 commit comments