mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MultiCombobox: Add basic controlled functionality (#97117)
* Add basic controlled functionality * Fix controlled component * Make component completely controlled * Fix PR feedback * Add support for number values and values not in options * Fix TS errors * Fix test feedback * Extract function
This commit is contained in:
parent
e0935246a3
commit
99395b62d4
@ -1,3 +1,5 @@
|
|||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { useArgs } from '@storybook/preview-api';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import { MultiCombobox } from './MultiCombobox';
|
import { MultiCombobox } from './MultiCombobox';
|
||||||
@ -7,17 +9,34 @@ const meta: Meta<typeof MultiCombobox> = {
|
|||||||
component: MultiCombobox,
|
component: MultiCombobox,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commonArgs = {
|
||||||
|
options: [
|
||||||
|
{ label: 'Option 1', value: 'option1' },
|
||||||
|
{ label: 'Option 2', value: 'option2' },
|
||||||
|
{ label: 'Option 3', value: 'option3' },
|
||||||
|
],
|
||||||
|
value: ['option2'],
|
||||||
|
placeholder: 'Select multiple options...',
|
||||||
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof MultiCombobox>;
|
type Story = StoryObj<typeof MultiCombobox>;
|
||||||
|
|
||||||
export const Basic: Story = {
|
export const Basic: Story = {
|
||||||
args: {
|
args: commonArgs,
|
||||||
options: [
|
render: (args) => {
|
||||||
{ label: 'Option 1', value: 'option1' },
|
const [{ value }, setArgs] = useArgs();
|
||||||
{ label: 'Option 2', value: 'option2' },
|
|
||||||
{ label: 'Option 3', value: 'option3' },
|
return (
|
||||||
],
|
<MultiCombobox
|
||||||
placeholder: 'Select multiple options...',
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(val) => {
|
||||||
|
action('onChange')(val);
|
||||||
|
setArgs({ value: val });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent, { UserEvent } from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
||||||
|
|
||||||
|
describe('MultiCombobox', () => {
|
||||||
|
let user: UserEvent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with options', async () => {
|
||||||
|
const options = [
|
||||||
|
{ label: 'A', value: 'a' },
|
||||||
|
{ label: 'B', value: 'b' },
|
||||||
|
{ label: 'C', value: 'c' },
|
||||||
|
];
|
||||||
|
render(<MultiCombobox options={options} value={[]} onChange={jest.fn()} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with value', () => {
|
||||||
|
const options = [
|
||||||
|
{ label: 'A', value: 'a' },
|
||||||
|
{ label: 'B', value: 'b' },
|
||||||
|
{ label: 'C', value: 'c' },
|
||||||
|
];
|
||||||
|
render(<MultiCombobox options={options} value={['a']} onChange={jest.fn()} />);
|
||||||
|
expect(screen.getByText('A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with placeholder', () => {
|
||||||
|
const options = [
|
||||||
|
{ label: 'A', value: 'a' },
|
||||||
|
{ label: 'B', value: 'b' },
|
||||||
|
{ label: 'C', value: 'c' },
|
||||||
|
];
|
||||||
|
render(<MultiCombobox options={options} value={[]} onChange={jest.fn()} placeholder="Select" />);
|
||||||
|
expect(screen.getByPlaceholderText('Select')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a', 'b', 'c'],
|
||||||
|
[1, 2, 3],
|
||||||
|
])('should call onChange with the correct values', async (first, second, third) => {
|
||||||
|
const options = [
|
||||||
|
{ label: 'A', value: first },
|
||||||
|
{ label: 'B', value: second },
|
||||||
|
{ label: 'C', value: third },
|
||||||
|
];
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
const ControlledMultiCombobox = (props: MultiComboboxProps<string | number>) => {
|
||||||
|
const [value, setValue] = React.useState<string[] | number[]>([]);
|
||||||
|
return (
|
||||||
|
<MultiCombobox
|
||||||
|
{...props}
|
||||||
|
value={value}
|
||||||
|
onChange={(val) => {
|
||||||
|
//@ts-expect-error Don't do this for real life use cases
|
||||||
|
setValue(val ?? []);
|
||||||
|
onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(<ControlledMultiCombobox options={options} value={[]} onChange={onChange} />);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await user.click(input);
|
||||||
|
await user.click(await screen.findByRole('option', { name: 'A' }));
|
||||||
|
|
||||||
|
//Second option
|
||||||
|
await user.click(screen.getByRole('option', { name: 'C' }));
|
||||||
|
|
||||||
|
//Deselect
|
||||||
|
await user.click(screen.getByRole('option', { name: 'A' }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, [first]);
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(2, [first, third]);
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(3, [third]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to render a valie that is not in the options', async () => {
|
||||||
|
const options = [
|
||||||
|
{ label: 'A', value: 'a' },
|
||||||
|
{ label: 'B', value: 'b' },
|
||||||
|
{ label: 'C', value: 'c' },
|
||||||
|
];
|
||||||
|
render(<MultiCombobox options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />);
|
||||||
|
await user.click(screen.getByRole('combobox'));
|
||||||
|
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -1,5 +1,5 @@
|
|||||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
import { Checkbox } from '../Forms/Checkbox';
|
import { Checkbox } from '../Forms/Checkbox';
|
||||||
@ -11,22 +11,34 @@ import { ValuePill } from './ValuePill';
|
|||||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||||
|
|
||||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||||
value?: string | Array<ComboboxOption<T>>;
|
value?: T[] | Array<ComboboxOption<T>>;
|
||||||
onChange: (items?: Array<ComboboxOption<T>>) => void;
|
onChange: (items?: T[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||||
|
|
||||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||||
const { options, placeholder } = props;
|
const { options, placeholder, onChange, value } = props;
|
||||||
|
const isAsync = typeof options === 'function';
|
||||||
|
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
if (!value || isAsync) {
|
||||||
|
//TODO handle async
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSelectedItemsFromValue<T>(value, options);
|
||||||
|
}, [value, options, isAsync]);
|
||||||
|
|
||||||
const multiStyles = useStyles2(getMultiComboboxStyles);
|
const multiStyles = useStyles2(getMultiComboboxStyles);
|
||||||
|
|
||||||
const isAsync = typeof options === 'function';
|
|
||||||
|
|
||||||
const [items, _baseSetItems] = useState(isAsync ? [] : options);
|
const [items, _baseSetItems] = useState(isAsync ? [] : options);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedItems, setSelectedItems] = useState<Array<ComboboxOption<T>>>([]);
|
|
||||||
|
const isOptionSelected = useCallback(
|
||||||
|
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||||
|
[selectedItems]
|
||||||
|
);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
@ -39,9 +51,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
if (newSelectedItems) {
|
if (newSelectedItems) {
|
||||||
setSelectedItems(newSelectedItems);
|
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -55,26 +68,37 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
getInputProps,
|
getInputProps,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
//selectedItem,
|
|
||||||
} = useCombobox({
|
} = useCombobox({
|
||||||
isOpen,
|
isOpen,
|
||||||
items,
|
items,
|
||||||
itemToString,
|
itemToString,
|
||||||
inputValue,
|
inputValue,
|
||||||
//defaultHighlightedIndex: 0,
|
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
|
stateReducer: (state, actionAndChanges) => {
|
||||||
|
const { changes, type } = actionAndChanges;
|
||||||
|
switch (type) {
|
||||||
|
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||||
|
case useCombobox.stateChangeTypes.ItemClick:
|
||||||
|
return {
|
||||||
|
...changes,
|
||||||
|
isOpen: true,
|
||||||
|
defaultHighlightedIndex: 0,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||||
case useCombobox.stateChangeTypes.ItemClick:
|
case useCombobox.stateChangeTypes.ItemClick:
|
||||||
if (newSelectedItem) {
|
if (newSelectedItem) {
|
||||||
const isAlreadySelected = selectedItems.some((opt) => opt.value === newSelectedItem.value);
|
if (!isOptionSelected(newSelectedItem)) {
|
||||||
if (!isAlreadySelected) {
|
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
||||||
setSelectedItems([...selectedItems, newSelectedItem]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
removeSelectedItem(newSelectedItem);
|
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case useCombobox.stateChangeTypes.InputBlur:
|
case useCombobox.stateChangeTypes.InputBlur:
|
||||||
@ -115,7 +139,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
<div>
|
<div>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const itemProps = getItemProps({ item, index });
|
const itemProps = getItemProps({ item, index });
|
||||||
const isSelected = selectedItems.some((opt) => opt.value === item.value);
|
const isSelected = isOptionSelected(item);
|
||||||
|
const id = 'multicombobox-option-' + item.value.toString();
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@ -124,8 +149,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
{/* Add styling with virtualization */}
|
{/* Add styling with virtualization */}
|
||||||
<Checkbox key={`${item.value}${index}`} value={isSelected} />
|
<Checkbox key={id} value={isSelected} aria-labelledby={id} />
|
||||||
<OptionListItem option={item} />
|
<OptionListItem option={item} id={id} />
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -136,3 +161,44 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle values that are not in options
|
||||||
|
for (const [index, val] of value.entries()) {
|
||||||
|
if (resultingItems[index] === undefined) {
|
||||||
|
resultingItems[index] = { value: val };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultingItems.filter((item) => item !== undefined); // TODO: Not actually needed, but TS complains
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComboboxOptions<T extends string | number>(
|
||||||
|
value: T[] | Array<ComboboxOption<T>>
|
||||||
|
): value is Array<ComboboxOption<T>> {
|
||||||
|
return typeof value[0] === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComboboxOptionsValues<T extends string | number>(optionArray: Array<ComboboxOption<T>>) {
|
||||||
|
return optionArray.map((option) => option.value);
|
||||||
|
}
|
||||||
|
@ -5,13 +5,16 @@ import { getComboboxStyles } from './getComboboxStyles';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
option: ComboboxOption<string | number>;
|
option: ComboboxOption<string | number>;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionListItem = ({ option }: Props) => {
|
export const OptionListItem = ({ option, id }: Props) => {
|
||||||
const styles = useStyles2(getComboboxStyles);
|
const styles = useStyles2(getComboboxStyles);
|
||||||
return (
|
return (
|
||||||
<div className={styles.optionBody}>
|
<div className={styles.optionBody}>
|
||||||
<span className={styles.optionLabel}>{option.label ?? option.value}</span>
|
<span className={styles.optionLabel} id={id}>
|
||||||
|
{option.label ?? option.value}
|
||||||
|
</span>
|
||||||
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
|
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user