MultiCombobox: Add "All" option (#98377)
* MultiCombobox: Add All option * Translate * Add prop to show All option * Change variable name * betterer update * Extract variable * Update packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> * Return All item * Update checkboxes * Add filtering functionality * Handle deduplication when selecting already selected items * Performance improvements when selecting and modifying all items * Handle bug with isOpen for tests to pass * Small fixes * Add filtered phrase * Address PR feedback * Reset okg/services from main * Reset from main * Restore main * Add counter to filtered all * Fix OptionListItem * Hide all when there are no results * Refactor to use useMemo instead * Fix comments * Remove useEffect --------- Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com>
This commit is contained in:
co-authored by
Tobias Skarhed
Tobias Skarhed
parent
c1364d6be6
commit
b3b044b54b
@@ -541,6 +541,9 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Combobox/ValuePill.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
|
||||
@@ -13,11 +13,11 @@ const meta: Meta<typeof MultiCombobox> = {
|
||||
|
||||
const commonArgs = {
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Option 4', value: 'option4' },
|
||||
{ label: 'Option 5', value: 'option5' },
|
||||
{ label: 'wasd - 1', value: 'option1' },
|
||||
{ label: 'wasd - 2', value: 'option2' },
|
||||
{ label: 'wasd - 3', value: 'option3' },
|
||||
{ label: 'asdf - 1', value: 'option4' },
|
||||
{ label: 'asdf - 2', value: 'option5' },
|
||||
],
|
||||
value: ['option2'],
|
||||
placeholder: 'Select multiple options...',
|
||||
|
||||
@@ -36,8 +36,8 @@ describe('MultiCombobox', () => {
|
||||
const input = screen.getByRole('combobox');
|
||||
user.click(input);
|
||||
expect(await screen.findByText('A')).toBeInTheDocument();
|
||||
expect(await screen.findByText('B')).toBeInTheDocument();
|
||||
expect(await screen.findByText('C')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with value', () => {
|
||||
@@ -85,6 +85,7 @@ describe('MultiCombobox', () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ControlledMultiCombobox options={options} value={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -111,4 +112,49 @@ describe('MultiCombobox', () => {
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('all option', () => {
|
||||
it('should render all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox width={200} options={options} value={['a']} onChange={jest.fn()} enableAllOption />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
expect(await screen.findByRole('option', { name: 'All' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(<MultiCombobox width={200} options={options} value={['a']} onChange={onChange} enableAllOption />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByText('All'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should deselect all option', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<MultiCombobox width={200} options={options} value={['a', 'b', 'c']} onChange={onChange} enableAllOption />
|
||||
);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByRole('option', { name: 'All' }));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { Box } from '../Layout/Box/Box';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
@@ -20,17 +21,20 @@ import { itemFilter, itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { useMeasureMulti } from './useMeasureMulti';
|
||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||
|
||||
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: T[]) => void;
|
||||
enableAllOption?: boolean;
|
||||
}
|
||||
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
const { options, placeholder, onChange, value, width, invalid, loading, disabled } = props;
|
||||
const { options, placeholder, onChange, value, width, enableAllOption, invalid, loading, disabled } = props;
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
@@ -45,15 +49,30 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const [baseItems, baseSetItems] = useState(isAsync ? [] : options);
|
||||
const allOptionItem = useMemo(() => {
|
||||
return {
|
||||
label:
|
||||
inputValue === ''
|
||||
? t('multicombobox.all.title', 'All')
|
||||
: t('multicombobox.all.title-filtered', 'All (filtered)'),
|
||||
// Type casting needed to make this work when T is a number
|
||||
value: ALL_OPTION_VALUE,
|
||||
} as ComboboxOption<T>;
|
||||
}, [inputValue]);
|
||||
|
||||
const items = useMemo(() => baseItems.filter(itemFilter(inputValue)), [baseItems, inputValue]);
|
||||
const baseItems = useMemo(() => {
|
||||
return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options;
|
||||
}, [options, enableAllOption, allOptionItem, isAsync]);
|
||||
|
||||
// TODO: Improve this with async
|
||||
useEffect(() => {
|
||||
baseSetItems(isAsync ? [] : options);
|
||||
}, [options, isAsync]);
|
||||
const items = useMemo(() => {
|
||||
const newItems = baseItems.filter(itemFilter(inputValue));
|
||||
|
||||
if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
@@ -125,6 +144,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
// Handle All functionality
|
||||
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
|
||||
const allFilteredSelected = selectedItems.length === items.length - 1;
|
||||
let newSelectedItems = allFilteredSelected && inputValue === '' ? [] : baseItems.slice(1);
|
||||
|
||||
if (!allFilteredSelected && inputValue !== '') {
|
||||
// Select all currently filtered items and deduplicate
|
||||
newSelectedItems = [...new Set([...selectedItems, ...items.slice(1)])];
|
||||
}
|
||||
|
||||
if (allFilteredSelected && inputValue !== '') {
|
||||
// Deselect all currently filtered items
|
||||
const filteredSet = new Set(items.slice(1).map((item) => item.value));
|
||||
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
|
||||
}
|
||||
|
||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||
break;
|
||||
}
|
||||
if (newSelectedItem) {
|
||||
if (!isOptionSelected(newSelectedItem)) {
|
||||
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
||||
@@ -136,6 +174,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? '');
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputClick:
|
||||
setIsOpen(true);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -145,13 +185,15 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const virtualizerOptions = {
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) => (items[index].description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT),
|
||||
estimateSize: (index: number) =>
|
||||
'description' in items[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virtualizerOptions);
|
||||
|
||||
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
|
||||
// Selected items that show up in the input field
|
||||
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
@@ -159,7 +201,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
style={{ width: width === 'auto' ? undefined : width }}
|
||||
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
|
||||
ref={measureRef}
|
||||
onClick={() => !disabled && selectedItems.length > 0 && setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{visibleItems.map((item, index) => (
|
||||
@@ -174,7 +215,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
{itemToString(item)}
|
||||
</ValuePill>
|
||||
))}
|
||||
{selectedItems.length > shownItems && !isOpen && (
|
||||
{selectedItems.length > visibleItems.length && (
|
||||
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={counterMeasureRef}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text>...</Text>
|
||||
@@ -182,7 +223,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
interactive
|
||||
content={
|
||||
<>
|
||||
{selectedItems.slice(shownItems).map((item) => (
|
||||
{selectedItems.slice(visibleItems.length).map((item) => (
|
||||
<div key={item.value}>{itemToString(item)}</div>
|
||||
))}
|
||||
</>
|
||||
@@ -193,15 +234,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
</Box>
|
||||
)}
|
||||
<input
|
||||
className={cx(multiStyles.input, {
|
||||
[multiStyles.inputClosed]: !isOpen && selectedItems.length > 0,
|
||||
})}
|
||||
className={multiStyles.input}
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
disabled,
|
||||
preventKeyAction: isOpen,
|
||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
||||
onFocus: () => setIsOpen(true),
|
||||
onFocus: () => !disabled && setIsOpen(true),
|
||||
})
|
||||
)}
|
||||
/>
|
||||
@@ -227,6 +266,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const itemProps = getItemProps({ item, index });
|
||||
const isSelected = isOptionSelected(item);
|
||||
const id = 'multicombobox-option-' + item.value.toString();
|
||||
const isAll = item.value === ALL_OPTION_VALUE;
|
||||
const allItemsSelected =
|
||||
items[0]?.value === ALL_OPTION_VALUE && selectedItems.length === items.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${item.value}-${index}`}
|
||||
@@ -238,13 +281,23 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Checkbox
|
||||
key={id}
|
||||
value={isSelected}
|
||||
value={allItemsSelected || isSelected}
|
||||
indeterminate={isAll && selectedItems.length > 0 && !allItemsSelected}
|
||||
aria-labelledby={id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<OptionListItem option={item} id={id} />
|
||||
<OptionListItem
|
||||
label={
|
||||
isAll
|
||||
? (item.label ?? item.value.toString()) +
|
||||
(isAll && inputValue !== '' ? ` (${items.length - 1})` : '')
|
||||
: (item.label ?? item.value.toString())
|
||||
}
|
||||
description={item?.description}
|
||||
id={id}
|
||||
/>
|
||||
</Stack>
|
||||
</li>
|
||||
);
|
||||
@@ -262,31 +315,29 @@ function getSelectedItemsFromValue<T extends string | number>(
|
||||
value: T[] | Array<ComboboxOption<T>>,
|
||||
options: Array<ComboboxOption<T>>
|
||||
) {
|
||||
if (!isComboboxOptions(value)) {
|
||||
const resultingItems: Array<ComboboxOption<T> | undefined> = [];
|
||||
if (isComboboxOptions(value)) {
|
||||
return value;
|
||||
}
|
||||
const valueMap = new Map(value.map((val, index) => [val, index]));
|
||||
const resultingItems: Array<ComboboxOption<T>> = [];
|
||||
|
||||
for (const item of options) {
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (val === item.value) {
|
||||
resultingItems[index] = item;
|
||||
}
|
||||
}
|
||||
if (resultingItems.length === value.length && !resultingItems.includes(undefined)) {
|
||||
// We found all items for the values
|
||||
break;
|
||||
}
|
||||
for (const option of options) {
|
||||
const index = valueMap.get(option.value);
|
||||
if (index !== undefined) {
|
||||
resultingItems[index] = option;
|
||||
valueMap.delete(option.value);
|
||||
}
|
||||
|
||||
// Handle values that are not in options
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (resultingItems[index] === undefined) {
|
||||
resultingItems[index] = { value: val };
|
||||
}
|
||||
if (valueMap.size === 0) {
|
||||
// We found all values
|
||||
break;
|
||||
}
|
||||
return resultingItems.filter((item) => item !== undefined); // TODO: Not actually needed, but TS complains
|
||||
}
|
||||
|
||||
return value;
|
||||
// Handle items that are not in options
|
||||
for (const [val, index] of valueMap) {
|
||||
resultingItems[index] = { value: val };
|
||||
}
|
||||
return resultingItems;
|
||||
}
|
||||
|
||||
function isComboboxOptions<T extends string | number>(
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useStyles2 } from '../../themes';
|
||||
|
||||
import { ComboboxOption } from './Combobox';
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
interface Props {
|
||||
option: ComboboxOption<string | number>;
|
||||
label: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const OptionListItem = ({ option, id }: Props) => {
|
||||
export const OptionListItem = ({ label, description, id }: Props) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
return (
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel} id={id}>
|
||||
{option.label ?? option.value}
|
||||
{label}
|
||||
</span>
|
||||
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
|
||||
{description && <span className={styles.optionDescription}>{description}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComboboxOption } from './Combobox';
|
||||
import { ALL_OPTION_VALUE } from './MultiCombobox';
|
||||
|
||||
export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
|
||||
if (!item) {
|
||||
@@ -17,7 +18,8 @@ export function itemFilter<T extends string | number>(inputValue: string) {
|
||||
return (
|
||||
!inputValue ||
|
||||
item.label?.toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue)
|
||||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item.value.toString() === ALL_OPTION_VALUE
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,12 +39,7 @@ export const getMultiComboboxStyles = (
|
||||
outline: 'none',
|
||||
},
|
||||
}),
|
||||
inputClosed: css({
|
||||
width: 0,
|
||||
flexGrow: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}),
|
||||
|
||||
pillWrapper: css({
|
||||
display: 'inline-flex',
|
||||
flexWrap: isOpen ? 'wrap' : 'nowrap',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ComboboxOption } from './Combobox';
|
||||
const FONT_SIZE = 12;
|
||||
const EXTRA_PILL_SIZE = 50;
|
||||
const EXTRA_PILL_DISABLED_SIZE = 10;
|
||||
export const MAX_SHOWN_ITEMS = 15;
|
||||
|
||||
/**
|
||||
* Updates the number of shown items in the multi combobox based on the available width.
|
||||
@@ -34,8 +35,8 @@ export function useMeasureMulti<T extends string | number>(
|
||||
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
|
||||
if (currWidth > maxWidth) {
|
||||
// If there is no space for that item, show the current number of items,
|
||||
// but always show at least 1 item
|
||||
setShownItems(i || 1);
|
||||
// but always show at least 1 item. Cap at maximum number of items.
|
||||
setShownItems(Math.min(i, MAX_SHOWN_ITEMS) || 1);
|
||||
break;
|
||||
}
|
||||
if (i === selectedItems.length - 1) {
|
||||
|
||||
@@ -2049,6 +2049,12 @@
|
||||
"title": "Why host with Grafana?"
|
||||
}
|
||||
},
|
||||
"multicombobox": {
|
||||
"all": {
|
||||
"title": "All",
|
||||
"title-filtered": "All (filtered)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"add-new-connections": {
|
||||
"title": "Add new connection"
|
||||
|
||||
@@ -2049,6 +2049,12 @@
|
||||
"title": "Ŵĥy ĥőşŧ ŵįŧĥ Ğřäƒäʼnä?"
|
||||
}
|
||||
},
|
||||
"multicombobox": {
|
||||
"all": {
|
||||
"title": "Åľľ",
|
||||
"title-filtered": "Åľľ (ƒįľŧęřęđ)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"add-new-connections": {
|
||||
"title": "Åđđ ʼnęŵ čőʼnʼnęčŧįőʼn"
|
||||
|
||||
Reference in New Issue
Block a user