Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* limitations under the License.
*/

import React from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
default as ReactSelect,
Props as ReactSelectProps,
Expand All @@ -40,16 +40,20 @@ interface MultiSelectProps extends ReactSelectProps<Option, true> {
options: Option[];
selected: Option[];
placeholder: string;
fixedColumn: string;
// Accept a single key or an array of keys for columns that are always
// selected, hidden from the dropdown, and preserved through Unselect All.
fixedColumn: string | string[];
columnLength: number;
style?: StylesConfig<Option, true>;
showSearch?: boolean;
showSelectAll?: boolean;
onChange: (arg0: ValueType<Option, true>) => void;
onTagClose: (arg0: string) => void;
}

// ------------- Component -------------- //

const Option: React.FC<OptionProps<Option, true>> = (props) => {
const OptionComponent: React.FC<OptionProps<Option, true>> = (props) => {
return (
<div>
<components.Option
Expand All @@ -66,9 +70,8 @@ const Option: React.FC<OptionProps<Option, true>> = (props) => {
<label>{props.label}</label>
</components.Option>
</div>
)
}

);
};

const MultiSelect: React.FC<MultiSelectProps> = ({
options = [],
Expand All @@ -80,14 +83,69 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
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] = useState('');
const [isMenuOpen, setIsMenuOpen] = 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 = useRef(false);
// Ref to the outer container div — used to detect "focus left the widget".
const containerRef = useRef<HTMLDivElement>(null);

// Normalise fixedColumn to an array of keys for uniform handling.
const fixedKeys: string[] = Array.isArray(fixedColumn)
? fixedColumn.filter(Boolean)
: fixedColumn ? [fixedColumn] : [];

const fixedOptions = options.filter((opt) => fixedKeys.includes(opt.value));
const selectableOptions = options.filter((opt) => !fixedKeys.includes(opt.value));

const ValueContainer = ({ children, ...props }: ValueContainerProps<Option, true>) => {
// Always-current values for use inside stable useMemo components.
const stateRef = useRef({
searchTerm,
setSearchTerm,
showSearch,
showSelectAll,
selected,
options,
selectableOptions,
fixedOptions,
fixedKeys,
onChange,
setIsMenuOpen,
containerRef
});
stateRef.current = {
searchTerm,
setSearchTerm,
showSearch,
showSelectAll,
selected,
options,
selectableOptions,
fixedOptions,
fixedKeys,
onChange,
setIsMenuOpen,
containerRef
};

const filteredOptions = useMemo(() => {
if (!showSearch || !searchTerm) return selectableOptions;
return selectableOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [options, searchTerm, showSearch]);

const ValueContainer = ({ children, ...vcProps }: ValueContainerProps<Option, true>) => {
return (
<components.ValueContainer {...props}>
<components.ValueContainer {...vcProps}>
{React.Children.map(children, (child) => (
((child as React.ReactElement<any, string
| React.JSXElementConstructor<any>>
Expand All @@ -97,44 +155,190 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
)}
{isDisabled
? placeholder
: `${placeholder}: ${selected.filter((opt) => opt.value !== fixedColumn).length} selected`
: `${placeholder}: ${selected.filter((opt) => !fixedKeys.includes(opt.value)).length} selected`
}
</components.ValueContainer>
);
};

const finalStyles = {...selectStyles, ...style ?? {}}
// Stable custom Input — suppresses react-select's blur-driven menu close
// while the user interacts with the search box inside the menu.
const InputComponent = useMemo(
() => (({ onBlur, ...inputProps }: any) => {
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
if (searchInteracting.current) return;
if (onBlur) onBlur(e);
};
return <input {...inputProps} onBlur={handleBlur} />;
}) as React.FC,
[] // searchInteracting captured by ref — always current
);
Comment thread
spacemonkd marked this conversation as resolved.
Outdated

const fixedOption = fixedColumn ? options.find((opt) => opt.value === fixedColumn) : undefined;
const selectableOptions = fixedColumn ? options.filter((opt) => opt.value !== fixedColumn) : options;
// Stable MenuList — created once, reads current values from stateRef at
// call time to avoid stale closures without re-creating the component type.
const MenuListComponent = useMemo(
() => ({ children, ...menuListProps }: any) => {
const {
searchTerm,
setSearchTerm,
showSearch,
showSelectAll,
selected,
selectableOptions,
fixedOptions,
options,
onChange
} = stateRef.current;
Comment thread
spacemonkd marked this conversation as resolved.
Outdated

return (
const allSelected = selectableOptions.length > 0 &&
selectableOptions.every((opt: Option) => selected.some((s: Option) => s.value === opt.value));

const handleSelectAll = () => {
onChange([...fixedOptions, ...selectableOptions]);
};

const handleUnselectAll = () => {
onChange(fixedOptions as Option[]);
};

return (
<components.MenuList {...menuListProps}>
{showSearch && (
<div
style={{ padding: '8px 12px' }}
onMouseDown={(e) => {
searchInteracting.current = true;
e.preventDefault();
}}
onClick={() => {
const input = (e: any) => e?.target?.closest('[data-search-wrapper]')?.querySelector('input');
const el = document.querySelector('[data-search-wrapper] input') as HTMLInputElement | null;
if (el) el.focus();
}}
onFocus={() => { searchInteracting.current = true; }}
onBlur={() => {
searchInteracting.current = false;
setTimeout(() => {
const container = stateRef.current.containerRef.current;
if (container && !container.contains(document.activeElement)) {
stateRef.current.setIsMenuOpen(false);
stateRef.current.setSearchTerm('');
}
}, 150);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
searchInteracting.current = false;
stateRef.current.setIsMenuOpen(false);
stateRef.current.setSearchTerm('');
}
e.stopPropagation();
}}
data-search-wrapper='true'
>
<input
type='text'
placeholder='Search...'
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #d9d9d9',
fontSize: '14px',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
)}
{showSelectAll && (
<div
style={{
padding: '6px 12px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center'
}}
onMouseDown={(e) => e.preventDefault()}
onClick={() => allSelected ? handleUnselectAll() : handleSelectAll()}
>
<input
type='checkbox'
checked={allSelected}
onChange={() => null}
style={{ marginRight: '8px', accentColor: '#1AA57A' }}
/>
<label style={{ cursor: 'pointer' }}>
{allSelected ? 'Unselect All' : 'Select All'}
</label>
</div>
)}
{children}
</components.MenuList>
);
},
[] // Stable reference — reads current values from stateRef
);

// Only intercept onMenuClose when showSearch is active so we can keep
// the dropdown open while the user interacts with the search box.
const handleMenuClose = useCallback(() => {
Comment thread
spacemonkd marked this conversation as resolved.
Outdated
if (!searchInteracting.current) {
setIsMenuOpen(false);
setSearchTerm('');
}
}, []);

const searchModeProps = showSearch
? {
menuIsOpen: isMenuOpen,
onMenuOpen: () => setIsMenuOpen(true),
onMenuClose: handleMenuClose
}
: {};

const finalStyles = { ...selectStyles, ...style ?? {} };

const select = (
<ReactSelect
{...props}
{...(searchModeProps as any)}
isMulti={true}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable={false}
isSearchable={false}
controlShouldRenderValue={false}
classNamePrefix='multi-select'
options={selectableOptions}
options={filteredOptions}
components={{
ValueContainer,
Option
Option: OptionComponent,
MenuList: MenuListComponent,
...(showSearch ? { Input: InputComponent } : {})
}}
menuPortalTarget={document.body}
placeholder={placeholder}
value={selected.filter((opt) => opt.value !== fixedColumn)}
value={selected.filter((opt) => !fixedKeys.includes(opt.value))}
isDisabled={isDisabled}
onChange={(selected: ValueType<Option, true>) => {
const selectedOpts = (selected as Option[]) ?? [];
const withFixed = fixedOption ? [fixedOption, ...selectedOpts] : selectedOpts;
onChange={(selectedValue: ValueType<Option, true>) => {
const selectedOpts = (selectedValue as Option[]) ?? [];
const withFixed = [...fixedOptions, ...selectedOpts];
if (selectedOpts.length === selectableOptions.length) return onChange!(options);
return onChange!(withFixed);
}}
styles={finalStyles} />
)
}
);

// Wrap in a container div only when showSearch is active so we have a
// boundary for detecting "focus left the widget" in the search onBlur.
return showSearch
? <div ref={containerRef}>{select}</div>
: select;
};

export default MultiSelect;
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,15 @@ export const COLUMNS: ColumnsType<Bucket> = [
title: 'Bucket',
dataIndex: 'name',
key: 'name',
fixed: 'left',
sorter: (a: Bucket, b: Bucket) => a.name.localeCompare(b.name),
defaultSortOrder: 'ascend' as const
},
{
title: 'Volume',
dataIndex: 'volumeName',
key: 'volumeName',
fixed: 'left',
sorter: (a: Bucket, b: Bucket) => a.volumeName.localeCompare(b.volumeName),
defaultSortOrder: 'ascend' as const
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,20 @@ const Buckets: React.FC<{}> = () => {
onChange={handleVolumeChange}
fixedColumn=''
onTagClose={() => { }}
columnLength={volumeOptions.length} />
columnLength={volumeOptions.length}
showSearch={true}
showSelectAll={true} />
<MultiSelect
options={columnOptions}
defaultValue={selectedColumns}
selected={selectedColumns}
placeholder='Columns'
onChange={handleColumnChange}
onTagClose={() => { }}
fixedColumn='name'
columnLength={COLUMNS.length} />
fixedColumn={['name', 'volumeName']}
columnLength={COLUMNS.length}
showSearch={true}
showSelectAll={true} />
<SingleSelect
options={LIMIT_OPTIONS}
defaultValue={selectedLimit}
Comment on lines 278 to 280
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to increase the z-index for this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added menuPortalTarget={document.body} to the Select in singleSelect.tsx. This portals the dropdown outside the component's DOM subtree - the same approach MultiSelect already uses. The selectStyles in select.constants.tsx already has menuPortal: zIndex: 9999 defined, so that rule now applies to SingleSelect as well with no further changes needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice approach!

Expand Down