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,70 @@ 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+ const isVisible = ( m : K8sKind ) =>
63+ ! blacklistGroups . has ( m . apiGroup ) &&
64+ ! blacklistResources . has ( `${ m . apiGroup } /${ m . apiVersion } .${ m . kind } ` ) &&
65+ ( _ . isEmpty ( m . verbs ) || _ . includes ( m . verbs , 'list' ) ) ;
66+
67+ // Pre-compute which group+kind combinations have a visible preferred version (O(n))
68+ const preferredGroupKinds = new Set < string > ( ) ;
69+ allModels . forEach ( ( m ) => {
70+ if ( groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion && isVisible ( m ) ) {
71+ preferredGroupKinds . add ( `${ m . kind } ~${ m . apiGroup } ` ) ;
7872 }
73+ } ) ;
7974
80- // Only show preferred version for resources in the same API group.
81- const preferred = ( m : K8sKind ) =>
82- groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion ;
83-
84- const sameGroupKind = ( m : K8sKind ) =>
85- m . kind === kind && m . apiGroup === apiGroup && m . apiVersion !== apiVersion ;
75+ return allModels
76+ . filter ( ( m ) => {
77+ if ( ! isVisible ( m ) ) {
78+ return false ;
79+ }
8680
87- return ! allModels . find ( ( m ) => sameGroupKind ( m ) && preferred ( m ) ) ;
88- } )
89- . toOrderedMap ( )
90- . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
81+ // Only show preferred version for resources in the same API group.
82+ // If a preferred version exists for this group+kind and this isn't it, skip.
83+ const key = `${ m . kind } ~${ m . apiGroup } ` ;
84+ if (
85+ preferredGroupKinds . has ( key ) &&
86+ groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion !== m . apiVersion
87+ ) {
88+ return false ;
89+ }
9190
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- } , [ ] ) ;
91+ return true ;
92+ } )
93+ . toOrderedMap ( )
94+ . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
95+ } , [ allModels , groupToVersionMap ] ) ;
96+
97+ const initialSelectOptions = useMemo < ExtendedSelectOptionProps [ ] > (
98+ ( ) =>
99+ resources . toArray ( ) . map ( ( resource ) => {
100+ const reference = referenceForModel ( resource ) ;
101+ return {
102+ value : reference ,
103+ children : reference ,
104+ shortNames : resource . shortNames ,
105+ // Pre-compute lowercase for filtering so we don't repeat it on every keystroke
106+ searchableText : reference . toLowerCase ( ) ,
107+ searchableShortNames : resource . shortNames ?. map ( ( s ) => s . toLowerCase ( ) ) ,
108+ } ;
109+ } ) ,
110+ [ resources ] ,
111+ ) ;
100112
101113 const filterGroupVersionKind = ( resourceList : string [ ] ) : string [ ] => {
102114 return resourceList . filter ( ( resource ) => {
@@ -116,40 +128,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
116128
117129 useEffect ( ( ) => {
118130 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- ) ;
131+ } , [ selected ] ) ;
144132
145- // Open the menu when the input value changes and the new value is not empty
146- if ( ! isOpen ) {
147- setIsOpen ( true ) ;
148- }
133+ const selectOptions = useMemo ( ( ) => {
134+ if ( ! inputValue ) {
135+ return initialSelectOptions ;
149136 }
150-
151- setSelectOptions ( newSelectOptions ) ;
152- // eslint-disable-next-line react-hooks/exhaustive-deps
137+ const lower = inputValue . toLowerCase ( ) ;
138+ return initialSelectOptions . filter (
139+ ( menuItem ) =>
140+ menuItem . searchableText ?. includes ( lower ) ||
141+ menuItem . searchableShortNames ?. some ( ( shortName ) => shortName . includes ( lower ) ) ,
142+ ) ;
153143 } , [ inputValue , initialSelectOptions ] ) ;
154144
155145 useEffect ( ( ) => {
@@ -161,27 +151,38 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161151 } , [ placeholderTextDefault , selectedOptions , t ] ) ;
162152
163153 const createItemId = ( value : any ) => `resource-dropdown-${ value . replace ( ' ' , '-' ) } ` ;
154+
155+ // Pre-compute a reference-to-model lookup map (O(1) per lookup instead of O(n))
156+ const referenceToModelMap = useMemo ( ( ) => {
157+ const map = new Map < string , K8sKind > ( ) ;
158+ resources . forEach ( ( r ) => map . set ( referenceForModel ( r ) , r ) ) ;
159+ return map ;
160+ } , [ resources ] ) ;
161+
164162 // Track duplicate names so we know when to show the group.
165- const kinds = resources . groupBy ( ( m ) => m . kind ) ;
163+ const kinds = useMemo ( ( ) => resources . groupBy ( ( m ) => m . kind ) , [ resources ] ) ;
166164 const isDup = ( kind ) => kinds . get ( kind ) . size > 1 ;
167165
168- const items = selectOptions . map ( ( option : SelectOptionProps , index ) => {
169- const model = resources . toArray ( ) . find ( ( resource : K8sKind ) => {
170- return option . value === referenceForModel ( resource ) ;
171- } ) ;
166+ const visibleSelectOptions = selectOptions . slice ( 0 , MAX_VISIBLE_ITEMS ) ;
167+ const items = visibleSelectOptions . map ( ( option : SelectOptionProps , index ) => {
168+ const ref = option . value as string ;
169+ const model = referenceToModelMap . get ( ref ) ;
170+ if ( ! model ) {
171+ return null ;
172+ }
172173
173174 return (
174175 < SelectOption
175- key = { referenceForModel ( model ) }
176- value = { referenceForModel ( model ) }
176+ key = { ref }
177+ value = { ref }
177178 hasCheckbox
178- isSelected = { selected . includes ( referenceForModel ( model ) ) }
179+ isSelected = { selected . includes ( ref ) }
179180 isFocused = { focusedItemIndex === index }
180- id = { createItemId ( referenceForModel ( model ) ) }
181+ id = { createItemId ( ref ) }
181182 >
182183 < span className = "co-resource-item" >
183184 < span className = "co-resource-icon--fixed-width" >
184- < ResourceIcon kind = { referenceForModel ( model ) } />
185+ < ResourceIcon kind = { ref } />
185186 </ span >
186187 < span className = "co-resource-item__resource-name" >
187188 < span >
@@ -206,9 +207,9 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206207 const recentSearches : JSX . Element [ ] =
207208 ! _ . isEmpty ( recentSelectedList ( recentSelected ) ) &&
208209 recentSelectedList ( recentSelected )
209- . splice ( 0 , RECENT_SEARCH_ITEMS )
210+ . slice ( 0 , RECENT_SEARCH_ITEMS )
210211 . map ( ( modelRef : K8sResourceKindReference ) => {
211- const model : K8sKind = resources . find ( ( m ) => referenceForModel ( m ) === modelRef ) ;
212+ const model = referenceToModelMap . get ( modelRef ) ;
212213 if ( model ) {
213214 return (
214215 < SelectOption
@@ -245,7 +246,6 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
245246 . filter ( ( item ) => item !== null ) ;
246247
247248 const onClear = ( ) => {
248- setClearItems ( true ) ;
249249 setRecentSelected ( JSON . stringify ( [ ] ) ) ;
250250 } ;
251251 const NO_RESULTS = 'no results' ;
@@ -273,10 +273,11 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273273 ) ;
274274 options . push ( < Divider key = { 3 } className = "co-select-group-divider" /> ) ;
275275 }
276+ const cleanItems = items . filter ( Boolean ) ;
276277 options . push (
277278 < Fragment key = "resource-items" >
278- { items . length > 0
279- ? items
279+ { cleanItems . length > 0
280+ ? cleanItems
280281 : [
281282 < SelectOption
282283 value = { NO_RESULTS }
@@ -286,6 +287,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286287 { t ( 'public~No results found' ) }
287288 </ SelectOption > ,
288289 ] }
290+ { selectOptions . length > MAX_VISIBLE_ITEMS && (
291+ < SelectOption
292+ value = "type-to-filter"
293+ key = "select-multi-typeahead-type-to-filter"
294+ isAriaDisabled = { true }
295+ >
296+ { t ( 'public~Showing {{visible}} of {{total}} resources. Type to filter.' , {
297+ visible : MAX_VISIBLE_ITEMS ,
298+ total : selectOptions . length ,
299+ } ) }
300+ </ SelectOption >
301+ ) }
289302 </ Fragment > ,
290303 ) ;
291304 return options ;
@@ -318,36 +331,51 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
318331 const onTextInputChange = ( _event : FormEvent < HTMLInputElement > , value : string ) => {
319332 setInputValue ( value ) ;
320333 resetActiveAndFocusedItem ( ) ;
334+ if ( value && ! isOpen ) {
335+ setIsOpen ( true ) ;
336+ }
321337 } ;
322338
323339 const onSelect = ( value : string ) => {
324340 if ( value && value !== NO_RESULTS ) {
325- setSelectedOptions (
326- selected . includes ( value )
327- ? selected . filter ( ( selection ) => selection !== value )
328- : [ ...selected , value ] ,
329- ) ;
341+ const newSelected = selected . includes ( value )
342+ ? selected . filter ( ( selection ) => selection !== value )
343+ : [ ...selected , value ] ;
344+ setSelectedOptions ( newSelected ) ;
330345 onChange ( value ) ;
346+
347+ // Update recently used resources
348+ if ( ! _ . isEmpty ( newSelected ) ) {
349+ setRecentSelected (
350+ JSON . stringify (
351+ _ . union (
352+ filterGroupVersionKind ( [ ...newSelected ] . reverse ( ) ) ,
353+ recentSelectedList ( recentSelected ) ,
354+ ) ,
355+ ) ,
356+ ) ;
357+ }
331358 }
332359
333360 textInputRef . current ?. focus ( ) ;
334361 } ;
335362
336363 const handleMenuArrowKeys = ( key : string ) => {
337364 let indexToFocus = 0 ;
365+ const maxIndex = Math . min ( selectOptions . length , MAX_VISIBLE_ITEMS ) - 1 ;
338366
339367 if ( ! isOpen ) {
340368 setIsOpen ( true ) ;
341369 }
342370
343- if ( selectOptions . every ( ( option ) => option . isDisabled ) ) {
371+ if ( maxIndex < 0 || selectOptions . every ( ( option ) => option . isDisabled ) ) {
344372 return ;
345373 }
346374
347375 if ( key === 'ArrowUp' ) {
348376 // When no index is set or at the first index, focus to the last, otherwise decrement focus index
349377 if ( focusedItemIndex === null || focusedItemIndex === 0 ) {
350- indexToFocus = selectOptions . length - 1 ;
378+ indexToFocus = maxIndex ;
351379 } else {
352380 indexToFocus = focusedItemIndex - 1 ;
353381 }
@@ -356,14 +384,14 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
356384 while ( selectOptions [ indexToFocus ] . isDisabled ) {
357385 indexToFocus -- ;
358386 if ( indexToFocus === - 1 ) {
359- indexToFocus = selectOptions . length - 1 ;
387+ indexToFocus = maxIndex ;
360388 }
361389 }
362390 }
363391
364392 if ( key === 'ArrowDown' ) {
365393 // 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 ) {
394+ if ( focusedItemIndex === null || focusedItemIndex === maxIndex ) {
367395 indexToFocus = 0 ;
368396 } else {
369397 indexToFocus = focusedItemIndex + 1 ;
@@ -372,7 +400,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
372400 // Skip disabled options
373401 while ( selectOptions [ indexToFocus ] . isDisabled ) {
374402 indexToFocus ++ ;
375- if ( indexToFocus === selectOptions . length ) {
403+ if ( indexToFocus > maxIndex ) {
376404 indexToFocus = 0 ;
377405 }
378406 }
@@ -479,6 +507,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479507interface ExtendedSelectOptionProps extends SelectOptionProps {
480508 /** Searchable short names for the select options */
481509 shortNames ?: string [ ] ;
510+ /** Pre-computed lowercase text for filtering */
511+ searchableText ?: string ;
512+ /** Pre-computed lowercase short names for filtering */
513+ searchableShortNames ?: string [ ] ;
482514}
483515
484516const resourceListDropdownStateToProps = ( { k8s } ) => ( {
0 commit comments