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>
pull/99245/head
Joao Silva 5 months ago committed by GitHub
parent c1364d6be6
commit b3b044b54b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 10
      packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx
  3. 50
      packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx
  4. 133
      packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
  5. 10
      packages/grafana-ui/src/components/Combobox/OptionListItem.tsx
  6. 4
      packages/grafana-ui/src/components/Combobox/filter.ts
  7. 7
      packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts
  8. 5
      packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts
  9. 6
      public/locales/en-US/grafana.json
  10. 6
      public/locales/pseudo-LOCALE/grafana.json

@ -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> = [];
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;
}
}
if (isComboboxOptions(value)) {
return value;
}
const valueMap = new Map(value.map((val, index) => [val, index]));
const resultingItems: Array<ComboboxOption<T>> = [];
// Handle values that are not in options
for (const [index, val] of value.entries()) {
if (resultingItems[index] === undefined) {
resultingItems[index] = { value: val };
}
for (const option of options) {
const index = valueMap.get(option.value);
if (index !== undefined) {
resultingItems[index] = option;
valueMap.delete(option.value);
}
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"

Loading…
Cancel
Save