From 081a7fd86d10d6a047eb1c1c226dfd90afdca8f6 Mon Sep 17 00:00:00 2001 From: Arun Sarin Date: Wed, 4 Mar 2026 23:44:50 +0530 Subject: [PATCH] HDDS-14727 Improvements on File Size Distribution Tab - Add search and select/unselect all in volume and bucket dropdowns. --- .../v2/components/plots/insightsFilePlot.tsx | 4 + .../src/v2/components/select/multiSelect.tsx | 206 +++++++++++++++++- .../src/v2/constants/select.constants.tsx | 3 +- .../src/v2/pages/insights/insights.tsx | 1 + 4 files changed, 204 insertions(+), 10 deletions(-) diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/insightsFilePlot.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/insightsFilePlot.tsx index aa35ac0d96d4..51e6117a9aad 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/insightsFilePlot.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/insightsFilePlot.tsx @@ -227,6 +227,8 @@ const FileSizeDistribution: React.FC = ({ onTagClose={() => { }} fixedColumn='' columnLength={volumeOptions.length} + showSearch={true} + showSelectAll={true} style={{ control: (baseStyles, state) => ({ ...baseStyles, @@ -243,6 +245,8 @@ const FileSizeDistribution: React.FC = ({ fixedColumn='' columnLength={bucketOptions.length} isDisabled={!isBucketSelectionEnabled} + showSearch={true} + showSelectAll={true} style={{ control: (baseStyles, state) => ({ ...baseStyles, diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/select/multiSelect.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/select/multiSelect.tsx index 03dd12b56982..48806a03c995 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/select/multiSelect.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/select/multiSelect.tsx @@ -27,6 +27,7 @@ import { StylesConfig } from 'react-select'; +import Search from '@/v2/components/search/search'; import { selectStyles } from "@/v2/constants/select.constants"; @@ -43,6 +44,8 @@ interface MultiSelectProps extends ReactSelectProps { fixedColumn: string; columnLength: number; style?: StylesConfig; + showSearch?: boolean; + showSelectAll?: boolean; onChange: (arg0: ValueType) => void; onTagClose: (arg0: string) => void; } @@ -80,11 +83,57 @@ const MultiSelect: React.FC = ({ columnLength, tagRef, style, - onTagClose = () => { }, // Assign default value as a void function - onChange = () => { }, // Assign default value as a void function + showSearch = false, + showSelectAll = false, + onTagClose = () => { }, + onChange = () => { }, ...props }) => { + const [searchTerm, setSearchTerm] = React.useState(''); + // Controlled menu-open state — only used when showSearch=true so we can + // keep the dropdown open while the user interacts with the Search box. + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + // True while the user's pointer/keyboard focus is inside the search wrapper. + // Read by the stable InputComponent closure to decide whether to suppress blur. + const searchInteracting = React.useRef(false); + // Ref to the search wrapper div (used in onClick to focus the inner ). + const searchWrapperRef = React.useRef(null); + // Ref to the outer container div so we can detect "focus left the widget". + const containerRef = React.useRef(null); + + // Always-current values for use inside stable useMemo components. + const stateRef = React.useRef({ + searchTerm, + setSearchTerm, + showSearch, + showSelectAll, + selected, + options, + onChange, + setIsMenuOpen, + containerRef + }); + stateRef.current = { + searchTerm, + setSearchTerm, + showSearch, + showSelectAll, + selected, + options, + onChange, + setIsMenuOpen, + containerRef + }; + + const filteredOptions = React.useMemo(() => { + if (!showSearch || !searchTerm) return options; + return options.filter(opt => + opt.label.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [options, searchTerm, showSearch]); + const ValueContainer = ({ children, ...props }: ValueContainerProps) => { return ( @@ -98,16 +147,147 @@ const MultiSelect: React.FC = ({ {isDisabled ? placeholder : `${placeholder}: ${selected.length} selected` -} + } ); }; - const finalStyles = {...selectStyles, ...style ?? {}} + // ── Custom Input override (search mode only) ───────────────────────────── + // React-select v3's onInputBlur steals focus back to its hidden input whenever + // a child element gains focus. By intercepting onBlur here we suppress that + // call while the search box is active, preventing the dropdown from closing. + const InputComponent = React.useMemo( + () => (({ onBlur, ...inputProps }: any) => { + const handleBlur = (e: React.FocusEvent) => { + // While the user is interacting with the search box, skip react-select's + // onInputBlur so it does not steal focus back or close the menu. + if (searchInteracting.current) return; + if (onBlur) onBlur(e); + }; + return ; + }) as React.FC, + [] // searchInteracting captured by ref — always current + ); - return ( + // ── Stable MenuList ─────────────────────────────────────────────────────── + // Created once so react-select updates (not remounts) it on every render. + // All mutable values are read from stateRef.current at call time. + const MenuListComponent = React.useMemo( + () => ({ children, ...menuListProps }: any) => { + const { + searchTerm, + setSearchTerm, + showSearch, + showSelectAll, + selected, + options, + onChange + } = stateRef.current; + const allSelected = options.length > 0 && selected.length === options.length; + + return ( + + {showSearch && ( + + )} + {showSelectAll && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onChange(allSelected ? [] : options); + }} + > + {allSelected ? 'Unselect All' : 'Select All'} +
+ )} + {children} +
+ ); + }, + [] // Stable reference — reads current values from stateRef + ); + + // Only intercept onMenuClose when showSearch is active; otherwise let + // react-select manage open/close normally (preserving existing behaviour + // for all other MultiSelect usages such as the column picker). + const handleMenuClose = React.useCallback(() => { + if (!searchInteracting.current) { + setIsMenuOpen(false); + setSearchTerm(''); + } + }, []); + + const finalStyles = {...selectStyles, ...style ?? {}}; + + const searchModeProps = showSearch + ? { + menuIsOpen: isMenuOpen, + onMenuOpen: () => setIsMenuOpen(true), + onMenuClose: handleMenuClose + } + : {}; + + const select = ( = ({ isSearchable={false} controlShouldRenderValue={false} classNamePrefix='multi-select' - options={options.map((opt) => ({...opt, isDisabled: (opt.value === fixedColumn)}))} + options={filteredOptions.map((opt) => ({...opt, isDisabled: (opt.value === fixedColumn)}))} components={{ ValueContainer, - Option + Option, + MenuList: MenuListComponent, + // Override Input only when search is enabled so we can suppress the + // blur-driven close while the user types in the Search box. + ...(showSearch ? { Input: InputComponent } : {}) }} placeholder={placeholder} value={selected} @@ -128,7 +312,13 @@ const MultiSelect: React.FC = ({ return onChange!(selected); }} styles={finalStyles} /> - ) + ); + + // Wrap in a div only when showSearch is active so we have a container + // boundary for detecting "focus left the widget" in the search onBlur. + return showSearch + ?
{select}
+ : select; } export default MultiSelect; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/select.constants.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/select.constants.tsx index 465c2533bc23..a630c64a1f88 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/select.constants.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/select.constants.tsx @@ -48,8 +48,7 @@ export const selectStyles: StylesConfig = { padding: 0 }), menu: (baseStyles) => ({ - ...baseStyles, - height: 100 + ...baseStyles }), placeholder: (baseStyles) => ({ ...baseStyles, diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/insights/insights.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/insights/insights.tsx index 930f0a4a9790..ebbcd5924589 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/insights/insights.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/insights/insights.tsx @@ -90,6 +90,7 @@ const Insights: React.FC<{}> = () => { (map: Map>, current: FileCountResponse) => { const volume = current.volume; const bucket = current.bucket; + if (!volume || !bucket) return map; if (map.has(volume)) { const buckets = Array.from(map.get(volume)!); map.set(volume, new Set([...buckets, bucket]));