From b1ae6881298a8f57c0ee5057096670b8442457ad Mon Sep 17 00:00:00 2001 From: Binura Gunasekara Date: Fri, 6 Mar 2026 16:50:04 +1100 Subject: [PATCH] searchable EntityKindPicker Signed-off-by: Binura Gunasekara --- .../catalog/ChoreoEntityKindPicker.tsx | 227 ++++++++++++------ 1 file changed, 149 insertions(+), 78 deletions(-) diff --git a/packages/app/src/components/catalog/ChoreoEntityKindPicker.tsx b/packages/app/src/components/catalog/ChoreoEntityKindPicker.tsx index 4c20843cf..a741884a6 100644 --- a/packages/app/src/components/catalog/ChoreoEntityKindPicker.tsx +++ b/packages/app/src/components/catalog/ChoreoEntityKindPicker.tsx @@ -1,12 +1,16 @@ import { alertApiRef, useApi, useApp } from '@backstage/core-plugin-api'; import Box from '@material-ui/core/Box'; +import CircularProgress from '@material-ui/core/CircularProgress'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListSubheader from '@material-ui/core/ListSubheader'; -import MenuItem from '@material-ui/core/MenuItem'; -import Select from '@material-ui/core/Select'; +import Popper, { PopperProps } from '@material-ui/core/Popper'; +import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import Autocomplete, { + AutocompleteRenderGroupParams, +} from '@material-ui/lab/Autocomplete'; +import { useEffect, useMemo, useState } from 'react'; import { EntityKindFilter, useEntityList, @@ -98,19 +102,14 @@ const useStyles = makeStyles((theme: Theme) => color: theme.palette.text.primary, whiteSpace: 'nowrap', }, - select: { + autocomplete: { minWidth: 180, }, - renderValue: { + option: { display: 'flex', alignItems: 'center', - gap: theme.spacing(1), - '& svg': { - fontSize: '1.2rem', - color: theme.palette.text.secondary, - }, }, - subheader: { + groupHeader: { color: theme.palette.text.secondary, fontSize: theme.typography.caption.fontSize, fontWeight: theme.typography.fontWeightBold as number, @@ -126,9 +125,38 @@ const useStyles = makeStyles((theme: Theme) => fontSize: '1.2rem', }, }, + listbox: { + // Allow the dropdown to expand without early scrolling; + // only scroll when it approaches viewport height in short windows. + maxHeight: 'calc(100vh - 170px)', + }, }), ); +const UNGROUPED_KIND_CATEGORY = '__ungrouped__'; + +interface KindOption { + category: string; + kind: string; + label: string; +} + +const BottomPinnedPopper = (props: PopperProps) => ( + +); + // Hook to fetch all available Choreo entity kinds from the catalog function useAllKinds(): { allKinds: Map; @@ -264,7 +292,7 @@ export const ChoreoEntityKindPicker = (props: ChoreoEntityKindPickerProps) => { const alertApi = useApi(alertApiRef); - const { error, allKinds, selectedKind, setSelectedKind } = + const { loading, error, allKinds, selectedKind, setSelectedKind } = useEntityKindFilter({ initialFilter: initialFilter, }); @@ -297,94 +325,137 @@ export const ChoreoEntityKindPicker = (props: ChoreoEntityKindPickerProps) => { return available; }, [allKinds, allowedKinds]); - // Build grouped menu items - const menuItems = useMemo(() => { - if (error) return []; + const kindOptions = useMemo(() => { + if (error) return [] as KindOption[]; - const items: ReactNode[] = []; + const options: KindOption[] = []; - // Add Namespace (domain) as a standalone top-level item + // Keep Namespace as the first, ungrouped option. if (availableKinds.has('domain')) { - const DomainIcon = app.getSystemIcon('kind:domain'); - items.push( - - {DomainIcon && ( - - - - )} - {kindDisplayNames.domain} - , - ); + options.push({ + category: UNGROUPED_KIND_CATEGORY, + kind: 'domain', + label: kindDisplayNames.domain, + }); } for (const category of kindCategories) { - // Skip platform-only categories for non-platform engineers if (category.platformOnly && !isPlatformEngineer) continue; - // Filter to only kinds that exist in the catalog const visibleKinds = category.kinds.filter(k => availableKinds.has(k)); - - // Skip category if no kinds are available if (visibleKinds.length === 0) continue; - items.push( - - {category.label} - , - ); - for (const kind of visibleKinds) { - const KindIcon = app.getSystemIcon(`kind:${kind}`); - items.push( - - {KindIcon && ( - - - - )} - {kindDisplayNames[kind] || kind} - , - ); + options.push({ + category: category.label, + kind, + label: kindDisplayNames[kind] || kind, + }); } } - return items; - }, [availableKinds, isPlatformEngineer, error, classes, app]); + return options; + }, [availableKinds, isPlatformEngineer, error]); + + const selectedOption = useMemo(() => { + const normalizedKind = selectedKind.toLowerCase(); + const matchedOption = kindOptions.find( + option => option.kind === normalizedKind, + ); + + if (matchedOption) { + return matchedOption; + } + + return { + category: UNGROUPED_KIND_CATEGORY, + kind: normalizedKind, + label: kindDisplayNames[normalizedKind] || selectedKind, + }; + }, [kindOptions, selectedKind]); + + const autocompleteOptions = useMemo(() => { + const hasSelectedOption = kindOptions.some( + option => option.kind === selectedOption.kind, + ); + + if (hasSelectedOption) { + return kindOptions; + } + + return [selectedOption, ...kindOptions]; + }, [kindOptions, selectedOption]); + + const renderOption = (option: KindOption) => { + const KindIcon = app.getSystemIcon(`kind:${option.kind}`); + + return ( + + {KindIcon && ( + + + + )} + {option.label} + + ); + }; + + const renderGroup = (params: AutocompleteRenderGroupParams) => { + if (params.group === UNGROUPED_KIND_CATEGORY) { + return
  • {params.children}
  • ; + } + + return ( +
  • + + {params.group} + + {params.children} +
  • + ); + }; if (error) return null; return hidden ? null : ( Kind - + renderInput={params => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> ); };