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 { MultiCombobox } from './MultiCombobox';
|
||||
@ -7,17 +9,34 @@ const meta: Meta<typeof 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;
|
||||
|
||||
type Story = StoryObj<typeof MultiCombobox>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
],
|
||||
placeholder: 'Select multiple options...',
|
||||
args: commonArgs,
|
||||
render: (args) => {
|
||||
const [{ value }, setArgs] = useArgs();
|
||||
|
||||
return (
|
||||
<MultiCombobox
|
||||
{...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 { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
@ -11,22 +11,34 @@ import { ValuePill } from './ValuePill';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: string | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: Array<ComboboxOption<T>>) => void;
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
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>) => {
|
||||
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 isAsync = typeof options === 'function';
|
||||
|
||||
const [items, _baseSetItems] = useState(isAsync ? [] : options);
|
||||
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('');
|
||||
|
||||
@ -39,9 +51,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
if (newSelectedItems) {
|
||||
setSelectedItems(newSelectedItems);
|
||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -55,26 +68,37 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
//selectedItem,
|
||||
} = useCombobox({
|
||||
isOpen,
|
||||
items,
|
||||
itemToString,
|
||||
inputValue,
|
||||
//defaultHighlightedIndex: 0,
|
||||
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 }) => {
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
if (newSelectedItem) {
|
||||
const isAlreadySelected = selectedItems.some((opt) => opt.value === newSelectedItem.value);
|
||||
if (!isAlreadySelected) {
|
||||
setSelectedItems([...selectedItems, newSelectedItem]);
|
||||
if (!isOptionSelected(newSelectedItem)) {
|
||||
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
||||
break;
|
||||
}
|
||||
removeSelectedItem(newSelectedItem);
|
||||
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
|
||||
}
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
@ -115,7 +139,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<div>
|
||||
{items.map((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 (
|
||||
<li
|
||||
key={item.value}
|
||||
@ -124,8 +149,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
>
|
||||
{' '}
|
||||
{/* Add styling with virtualization */}
|
||||
<Checkbox key={`${item.value}${index}`} value={isSelected} />
|
||||
<OptionListItem option={item} />
|
||||
<Checkbox key={id} value={isSelected} aria-labelledby={id} />
|
||||
<OptionListItem option={item} id={id} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@ -136,3 +161,44 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
</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 {
|
||||
option: ComboboxOption<string | number>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const OptionListItem = ({ option }: Props) => {
|
||||
export const OptionListItem = ({ option, id }: Props) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
return (
|
||||
<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>}
|
||||
</div>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user