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 = 200 ;
3132
3233// Blacklist known duplicate resources.
3334const blacklistGroups = ImmutableSet ( [
@@ -51,52 +52,68 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
5152 true ,
5253 ) ;
5354 const [ selectedOptions , setSelectedOptions ] = useState ( selected ) ;
54- const [ initialSelectOptions , setInitialSelectOptions ] = useState < ExtendedSelectOptionProps [ ] > ( [ ] ) ;
55- const [ selectOptions , setSelectOptions ] = useState < ExtendedSelectOptionProps [ ] > (
56- initialSelectOptions ,
57- ) ;
5855 const [ inputValue , setInputValue ] = useState < string > ( '' ) ;
5956 const [ focusedItemIndex , setFocusedItemIndex ] = useState < number | null > ( null ) ;
6057 const [ activeItemId , setActiveItemId ] = useState < string | null > ( null ) ;
6158 const placeholderTextDefault = t ( 'public~Resources' ) ;
6259 const [ placeholder , setPlaceholder ] = useState ( placeholderTextDefault ) ;
6360 const textInputRef = useRef < HTMLInputElement > ( ) ;
6461
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 ;
62+ const resources = useMemo ( ( ) => {
63+ // Pre-compute which group+kind combinations have a preferred version (O(n))
64+ const preferredGroupKinds = new Set < string > ( ) ;
65+ allModels . forEach ( ( m ) => {
66+ if ( groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion ) {
67+ preferredGroupKinds . add ( `${ m . kind } ~${ m . apiGroup } ` ) ;
7868 }
69+ } ) ;
7970
80- // Only show preferred version for resources in the same API group.
81- const preferred = ( m : K8sKind ) =>
82- groupToVersionMap ?. [ m . apiGroup ] ?. preferredVersion === m . apiVersion ;
71+ return allModels
72+ . filter ( ( { apiGroup, apiVersion, kind, verbs } ) => {
73+ // Remove blacklisted items.
74+ if (
75+ blacklistGroups . has ( apiGroup ) ||
76+ blacklistResources . has ( `${ apiGroup } /${ apiVersion } .${ kind } ` )
77+ ) {
78+ return false ;
79+ }
8380
84- const sameGroupKind = ( m : K8sKind ) =>
85- m . kind === kind && m . apiGroup === apiGroup && m . apiVersion !== apiVersion ;
81+ // Only show resources that can be listed.
82+ if ( ! _ . isEmpty ( verbs ) && ! _ . includes ( verbs , 'list' ) ) {
83+ return false ;
84+ }
8685
87- return ! allModels . find ( ( m ) => sameGroupKind ( m ) && preferred ( m ) ) ;
88- } )
89- . toOrderedMap ( )
90- . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
86+ // Only show preferred version for resources in the same API group.
87+ // If a preferred version exists for this group+kind and this isn't it, skip.
88+ const key = `${ kind } ~${ apiGroup } ` ;
89+ if (
90+ preferredGroupKinds . has ( key ) &&
91+ groupToVersionMap ?. [ apiGroup ] ?. preferredVersion !== apiVersion
92+ ) {
93+ return false ;
94+ }
9195
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- } , [ ] ) ;
96+ return true ;
97+ } )
98+ . toOrderedMap ( )
99+ . sortBy ( ( { kind, apiGroup } ) => `${ kind } ${ apiGroup } ` ) ;
100+ } , [ allModels , groupToVersionMap ] ) ;
101+
102+ const initialSelectOptions = useMemo < ExtendedSelectOptionProps [ ] > (
103+ ( ) =>
104+ resources . toArray ( ) . map ( ( resource ) => {
105+ const reference = referenceForModel ( resource ) ;
106+ return {
107+ value : reference ,
108+ children : reference ,
109+ shortNames : resource . shortNames ,
110+ // Pre-compute lowercase for filtering so we don't repeat it on every keystroke
111+ searchableText : reference . toLowerCase ( ) ,
112+ searchableShortNames : resource . shortNames ?. map ( ( s ) => s . toLowerCase ( ) ) ,
113+ } ;
114+ } ) ,
115+ [ resources ] ,
116+ ) ;
100117
101118 const filterGroupVersionKind = ( resourceList : string [ ] ) : string [ ] => {
102119 return resourceList . filter ( ( resource ) => {
@@ -129,28 +146,25 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
129146 // eslint-disable-next-line react-hooks/exhaustive-deps
130147 } , [ selected , setRecentSelected ] ) ;
131148
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- ) ;
144-
145- // Open the menu when the input value changes and the new value is not empty
146- if ( ! isOpen ) {
147- setIsOpen ( true ) ;
148- }
149+ const selectOptions = useMemo ( ( ) => {
150+ if ( ! inputValue ) {
151+ return initialSelectOptions ;
149152 }
153+ const lower = inputValue . toLowerCase ( ) ;
154+ return initialSelectOptions . filter (
155+ ( menuItem ) =>
156+ menuItem . searchableText ?. includes ( lower ) ||
157+ menuItem . searchableShortNames ?. some ( ( shortName ) => shortName . includes ( lower ) ) ,
158+ ) ;
159+ } , [ inputValue , initialSelectOptions ] ) ;
150160
151- setSelectOptions ( newSelectOptions ) ;
161+ // Open the menu when the input value changes and the new value is not empty
162+ useEffect ( ( ) => {
163+ if ( inputValue && ! isOpen ) {
164+ setIsOpen ( true ) ;
165+ }
152166 // eslint-disable-next-line react-hooks/exhaustive-deps
153- } , [ inputValue , initialSelectOptions ] ) ;
167+ } , [ inputValue ] ) ;
154168
155169 useEffect ( ( ) => {
156170 setPlaceholder (
@@ -161,27 +175,37 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
161175 } , [ placeholderTextDefault , selectedOptions , t ] ) ;
162176
163177 const createItemId = ( value : any ) => `resource-dropdown-${ value . replace ( ' ' , '-' ) } ` ;
178+
179+ // Pre-compute a reference-to-model lookup map (O(1) per lookup instead of O(n))
180+ const referenceToModelMap = useMemo ( ( ) => {
181+ const map = new Map < string , K8sKind > ( ) ;
182+ resources . forEach ( ( r ) => map . set ( referenceForModel ( r ) , r ) ) ;
183+ return map ;
184+ } , [ resources ] ) ;
185+
164186 // Track duplicate names so we know when to show the group.
165- const kinds = resources . groupBy ( ( m ) => m . kind ) ;
187+ const kinds = useMemo ( ( ) => resources . groupBy ( ( m ) => m . kind ) , [ resources ] ) ;
166188 const isDup = ( kind ) => kinds . get ( kind ) . size > 1 ;
167189
168190 const items = selectOptions . map ( ( option : SelectOptionProps , index ) => {
169- const model = resources . toArray ( ) . find ( ( resource : K8sKind ) => {
170- return option . value === referenceForModel ( resource ) ;
171- } ) ;
191+ const ref = option . value as string ;
192+ const model = referenceToModelMap . get ( ref ) ;
193+ if ( ! model ) {
194+ return null ;
195+ }
172196
173197 return (
174198 < SelectOption
175- key = { referenceForModel ( model ) }
176- value = { referenceForModel ( model ) }
199+ key = { ref }
200+ value = { ref }
177201 hasCheckbox
178- isSelected = { selected . includes ( referenceForModel ( model ) ) }
202+ isSelected = { selected . includes ( ref ) }
179203 isFocused = { focusedItemIndex === index }
180- id = { createItemId ( referenceForModel ( model ) ) }
204+ id = { createItemId ( ref ) }
181205 >
182206 < span className = "co-resource-item" >
183207 < span className = "co-resource-icon--fixed-width" >
184- < ResourceIcon kind = { referenceForModel ( model ) } />
208+ < ResourceIcon kind = { ref } />
185209 </ span >
186210 < span className = "co-resource-item__resource-name" >
187211 < span >
@@ -206,7 +230,7 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
206230 const recentSearches : JSX . Element [ ] =
207231 ! _ . isEmpty ( recentSelectedList ( recentSelected ) ) &&
208232 recentSelectedList ( recentSelected )
209- . splice ( 0 , RECENT_SEARCH_ITEMS )
233+ . slice ( 0 , RECENT_SEARCH_ITEMS )
210234 . map ( ( modelRef : K8sResourceKindReference ) => {
211235 const model : K8sKind = resources . find ( ( m ) => referenceForModel ( m ) === modelRef ) ;
212236 if ( model ) {
@@ -273,10 +297,12 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
273297 ) ;
274298 options . push ( < Divider key = { 3 } className = "co-select-group-divider" /> ) ;
275299 }
300+ const visibleItems =
301+ items . length > MAX_VISIBLE_ITEMS ? items . slice ( 0 , MAX_VISIBLE_ITEMS ) : items ;
276302 options . push (
277303 < Fragment key = "resource-items" >
278- { items . length > 0
279- ? items
304+ { visibleItems . length > 0
305+ ? visibleItems
280306 : [
281307 < SelectOption
282308 value = { NO_RESULTS }
@@ -286,6 +312,18 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
286312 { t ( 'public~No results found' ) }
287313 </ SelectOption > ,
288314 ] }
315+ { items . length > MAX_VISIBLE_ITEMS && (
316+ < SelectOption
317+ value = "type-to-filter"
318+ key = "select-multi-typeahead-type-to-filter"
319+ isAriaDisabled = { true }
320+ >
321+ { t ( 'public~Showing {{visible}} of {{total}} resources. Type to filter.' , {
322+ visible : MAX_VISIBLE_ITEMS ,
323+ total : items . length ,
324+ } ) }
325+ </ SelectOption >
326+ ) }
289327 </ Fragment > ,
290328 ) ;
291329 return options ;
@@ -479,6 +517,10 @@ const ResourceListDropdown_: FC<ResourceListDropdownProps> = (props) => {
479517interface ExtendedSelectOptionProps extends SelectOptionProps {
480518 /** Searchable short names for the select options */
481519 shortNames ?: string [ ] ;
520+ /** Pre-computed lowercase text for filtering */
521+ searchableText ?: string ;
522+ /** Pre-computed lowercase short names for filtering */
523+ searchableShortNames ?: string [ ] ;
482524}
483525
484526const resourceListDropdownStateToProps = ( { k8s } ) => ( {
0 commit comments