mirror of https://github.com/grafana/grafana.git
MultiCombobox: Async options (#99469)
* remove managed isOpen state, add hook to abstract away options/async functionality * split useOptions into new file * refactor stories revert combobox stories to what's in main. I screwed up that rebase * change onChange type, clean up what calls onChange, add debounce and useLatestAsyncCall * tests (mid trying to figure out the act stuff) * tests * debounce-promise doesn't work with rollup? * just some minor code clean up * fix type import
This commit is contained in:
parent
af2c7a19d1
commit
9d635edd0e
|
@ -162,15 +162,15 @@ export const AsyncOptionsWithLabels: Story = {
|
|||
|
||||
return (
|
||||
<Field
|
||||
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
|
||||
label='Async options fn returns objects like { label: "Option 69", value: "69" }'
|
||||
description="Search for 'break' to see an error"
|
||||
>
|
||||
<Combobox
|
||||
{...args}
|
||||
{...dynamicArgs}
|
||||
onChange={(val: ComboboxOption | null) => {
|
||||
onChangeAction(val);
|
||||
setArgs({ value: val });
|
||||
onChange={(value: ComboboxOption | null) => {
|
||||
onChangeAction(value);
|
||||
setArgs({ value });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = {
|
|||
{...dynamicArgs}
|
||||
onChange={(value: ComboboxOption | null) => {
|
||||
onChangeAction(value);
|
||||
setArgs({ value: value });
|
||||
setArgs({ value });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
@ -2,8 +2,10 @@ import { action } from '@storybook/addon-actions';
|
|||
import { useArgs, useEffect, useState } from '@storybook/preview-api';
|
||||
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||
|
||||
import { Field } from '../Forms/Field';
|
||||
|
||||
import { MultiCombobox } from './MultiCombobox';
|
||||
import { generateOptions } from './storyUtils';
|
||||
import { generateOptions, fakeSearchAPI } from './storyUtils';
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
const meta: Meta<typeof MultiCombobox> = {
|
||||
|
@ -11,6 +13,9 @@ const meta: Meta<typeof MultiCombobox> = {
|
|||
component: MultiCombobox,
|
||||
};
|
||||
|
||||
const loadOptionsAction = action('options called');
|
||||
const onChangeAction = action('onChange called');
|
||||
|
||||
const commonArgs = {
|
||||
options: [
|
||||
{ label: 'wasd - 1', value: 'option1' },
|
||||
|
@ -40,7 +45,7 @@ export const Basic: Story = {
|
|||
{...args}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
action('onChange')(val);
|
||||
onChangeAction(val);
|
||||
setArgs({ value: val });
|
||||
}}
|
||||
/>
|
||||
|
@ -67,17 +72,14 @@ export const AutoSize: Story = {
|
|||
};
|
||||
|
||||
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
const [dynamicArgs, setArgs] = useArgs();
|
||||
|
||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
generateOptions(numberOfOptions).then((options) => {
|
||||
setIsLoading(false);
|
||||
setOptions(options);
|
||||
setValue([options[5].value]);
|
||||
});
|
||||
setTimeout(async () => {
|
||||
const options = await generateOptions(numberOfOptions);
|
||||
setOptions(options);
|
||||
}, 1000);
|
||||
}, [numberOfOptions]);
|
||||
|
||||
|
@ -85,12 +87,11 @@ const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...
|
|||
return (
|
||||
<MultiCombobox
|
||||
{...rest}
|
||||
loading={isLoading}
|
||||
{...dynamicArgs}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(opts) => {
|
||||
setValue(opts || []);
|
||||
action('onChange')(opts);
|
||||
setArgs({ value: opts });
|
||||
onChangeAction(opts);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -104,3 +105,71 @@ export const ManyOptions: StoryObj<ManyOptionsArgs> = {
|
|||
},
|
||||
render: ManyOptionsStory,
|
||||
};
|
||||
|
||||
function loadOptionsWithLabels(inputValue: string) {
|
||||
loadOptionsAction(inputValue);
|
||||
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`);
|
||||
}
|
||||
|
||||
export const AsyncOptionsWithLabels: Story = {
|
||||
name: 'Async - options returns labels',
|
||||
args: {
|
||||
options: loadOptionsWithLabels,
|
||||
value: [{ label: 'Option 69', value: '69' }],
|
||||
placeholder: 'Select an option',
|
||||
},
|
||||
render: (args) => {
|
||||
const [dynamicArgs, setArgs] = useArgs();
|
||||
|
||||
return (
|
||||
<Field
|
||||
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
|
||||
description="Search for 'break' to see an error"
|
||||
>
|
||||
<MultiCombobox
|
||||
{...args}
|
||||
{...dynamicArgs}
|
||||
onChange={(val) => {
|
||||
onChangeAction(val);
|
||||
setArgs({ value: val });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function loadOptionsOnlyValues(inputValue: string) {
|
||||
loadOptionsAction(inputValue);
|
||||
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) =>
|
||||
options.map((opt) => ({ value: opt.label! }))
|
||||
);
|
||||
}
|
||||
|
||||
export const AsyncOptionsWithOnlyValues: Story = {
|
||||
name: 'Async - options returns only values',
|
||||
args: {
|
||||
options: loadOptionsOnlyValues,
|
||||
value: [{ value: 'Option 69' }],
|
||||
placeholder: 'Select an option',
|
||||
},
|
||||
render: (args) => {
|
||||
const [dynamicArgs, setArgs] = useArgs();
|
||||
|
||||
return (
|
||||
<Field
|
||||
label='Async options fn returns objects like { value: "69" }'
|
||||
description="Search for 'break' to see an error"
|
||||
>
|
||||
<MultiCombobox
|
||||
{...args}
|
||||
{...dynamicArgs}
|
||||
onChange={(val) => {
|
||||
onChangeAction(val);
|
||||
setArgs({ value: val });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent, { UserEvent } from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
describe('MultiCombobox', () => {
|
||||
beforeAll(() => {
|
||||
|
@ -91,15 +92,18 @@ describe('MultiCombobox', () => {
|
|||
await user.click(input);
|
||||
await user.click(await screen.findByRole('option', { name: 'A' }));
|
||||
|
||||
//Second option
|
||||
// Second option
|
||||
await user.click(screen.getByRole('option', { name: 'C' }));
|
||||
|
||||
//Deselect
|
||||
// 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]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, [{ label: 'A', value: first }]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [
|
||||
{ label: 'A', value: first },
|
||||
{ label: 'C', value: third },
|
||||
]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, [{ label: 'C', value: third }]);
|
||||
});
|
||||
|
||||
it('should be able to render a value that is not in the options', async () => {
|
||||
|
@ -138,7 +142,11 @@ describe('MultiCombobox', () => {
|
|||
await user.click(input);
|
||||
await user.click(await screen.findByText('All'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deselect all option', async () => {
|
||||
|
@ -157,4 +165,138 @@ describe('MultiCombobox', () => {
|
|||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeAll(() => {
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onChangeHandler.mockReset();
|
||||
});
|
||||
|
||||
// Assume that most apis only return with the value
|
||||
const simpleAsyncOptions = [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }];
|
||||
|
||||
it('should allow async options', async () => {
|
||||
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
// Debounce
|
||||
await act(async () => jest.advanceTimersByTime(200));
|
||||
|
||||
expect(asyncOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow async options and select value', async () => {
|
||||
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'Option 3' });
|
||||
await user.click(item);
|
||||
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([simpleAsyncOptions[2]]);
|
||||
});
|
||||
|
||||
it('should retain values not returned by the async function', async () => {
|
||||
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||
render(<MultiCombobox options={asyncOptions} value={[{ value: 'Option 69' }]} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'Option 3' });
|
||||
await user.click(item);
|
||||
|
||||
expect(onChangeHandler).toHaveBeenCalledWith([{ value: 'Option 69' }, { value: 'Option 3' }]);
|
||||
});
|
||||
|
||||
it('should ignore late responses', async () => {
|
||||
const asyncOptions = jest.fn(async (searchTerm: string) => {
|
||||
if (searchTerm === 'a') {
|
||||
return promiseResolvesWith([{ value: 'first' }], 1500);
|
||||
} else if (searchTerm === 'ab') {
|
||||
return promiseResolvesWith([{ value: 'second' }], 500);
|
||||
} else if (searchTerm === 'abc') {
|
||||
return promiseResolvesWith([{ value: 'third' }], 800);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
await user.keyboard('a');
|
||||
act(() => jest.advanceTimersByTime(200)); // Skip debounce
|
||||
|
||||
await user.keyboard('b');
|
||||
act(() => jest.advanceTimersByTime(200)); // Skip debounce
|
||||
|
||||
await user.keyboard('c');
|
||||
act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored
|
||||
|
||||
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'third' })).not.toBeInTheDocument();
|
||||
|
||||
jest.advanceTimersByTime(800); // Resolve the third request, should be shown
|
||||
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('option', { name: 'third' })).toBeInTheDocument();
|
||||
|
||||
jest.advanceTimersByTime(1500); // Resolve the first request, should be ignored
|
||||
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'third' })).toBeInTheDocument();
|
||||
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
it('should debounce requests', async () => {
|
||||
const asyncOptions = jest.fn(async () => {
|
||||
return promiseResolvesWith([{ value: 'Option 3' }], 1);
|
||||
});
|
||||
|
||||
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
await user.keyboard('a');
|
||||
act(() => jest.advanceTimersByTime(10));
|
||||
|
||||
await user.keyboard('b');
|
||||
act(() => jest.advanceTimersByTime(10));
|
||||
|
||||
await user.keyboard('c');
|
||||
act(() => jest.advanceTimersByTime(200));
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'Option 3' });
|
||||
expect(item).toBeInTheDocument();
|
||||
|
||||
expect(asyncOptions).toHaveBeenCalledTimes(1);
|
||||
expect(asyncOptions).toHaveBeenCalledWith('abc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function promiseResolvesWith(value: ComboboxOption[], timeout = 0) {
|
||||
return new Promise<ComboboxOption[]>((resolve) => setTimeout(() => resolve(value), timeout));
|
||||
}
|
||||
|
|
|
@ -18,46 +18,25 @@ import { NotFoundError } from './MessageRows';
|
|||
import { OptionListItem } from './OptionListItem';
|
||||
import { SuffixIcon } from './SuffixIcon';
|
||||
import { ValuePill } from './ValuePill';
|
||||
import { itemFilter, itemToString } from './filter';
|
||||
import { itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
|
||||
import { useOptions } from './useOptions';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: T[]) => void;
|
||||
onChange: (option: Array<ComboboxOption<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,
|
||||
enableAllOption,
|
||||
invalid,
|
||||
loading,
|
||||
disabled,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
} = 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 { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth } = props;
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
@ -73,19 +52,22 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
} as ComboboxOption<T>;
|
||||
}, [inputValue]);
|
||||
|
||||
const baseItems = useMemo(() => {
|
||||
return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options;
|
||||
}, [options, enableAllOption, allOptionItem, isAsync]);
|
||||
// Handle async options and the 'All' option
|
||||
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options);
|
||||
const options = useMemo(() => {
|
||||
// Only add the 'All' option if there's more than 1 option
|
||||
const addAllOption = enableAllOption && baseOptions.length > 1;
|
||||
return addAllOption ? [allOptionItem, ...baseOptions] : baseOptions;
|
||||
}, [baseOptions, enableAllOption, allOptionItem]);
|
||||
const loading = props.loading || asyncLoading;
|
||||
|
||||
const items = useMemo(() => {
|
||||
const newItems = baseItems.filter(itemFilter(inputValue));
|
||||
|
||||
if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) {
|
||||
const selectedItems = useMemo(() => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
|
||||
return getSelectedItemsFromValue<T>(value, baseOptions);
|
||||
}, [value, baseOptions]);
|
||||
|
||||
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
|
||||
selectedItems,
|
||||
|
@ -98,48 +80,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
[selectedItems]
|
||||
);
|
||||
|
||||
const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({
|
||||
selectedItems, //initally selected items,
|
||||
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
if (newSelectedItems) {
|
||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||
}
|
||||
break;
|
||||
const { getSelectedItemProps, getDropdownProps, setSelectedItems, addSelectedItem, removeSelectedItem } =
|
||||
useMultipleSelection({
|
||||
selectedItems, // initally selected items,
|
||||
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
|
||||
switch (type) {
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionSetSelectedItems:
|
||||
// Unclear why newSelectedItems would be undefined, but this seems logical
|
||||
onChange(newSelectedItems ?? []);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes } = actionAndChanges;
|
||||
return {
|
||||
...changes,
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes } = actionAndChanges;
|
||||
return {
|
||||
...changes,
|
||||
|
||||
/**
|
||||
* TODO: Fix Hack!
|
||||
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
|
||||
* of breaking keyboard navigation.
|
||||
*
|
||||
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
|
||||
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
|
||||
* This only seems to happen when you deselect the last item in the selectedItems list.
|
||||
*
|
||||
* Check out:
|
||||
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
|
||||
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
|
||||
*
|
||||
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
|
||||
* and prevents the if statement in useMultipleSelection from focusing anything.
|
||||
*/
|
||||
activeIndex: -999,
|
||||
};
|
||||
},
|
||||
});
|
||||
/**
|
||||
* TODO: Fix Hack!
|
||||
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
|
||||
* of breaking keyboard navigation.
|
||||
*
|
||||
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
|
||||
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
|
||||
* This only seems to happen when you deselect the last item in the selectedItems list.
|
||||
*
|
||||
* Check out:
|
||||
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
|
||||
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
|
||||
*
|
||||
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
|
||||
* and prevents the if statement in useMultipleSelection from focusing anything.
|
||||
*/
|
||||
activeIndex: -999,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getToggleButtonProps,
|
||||
|
@ -150,12 +134,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
getInputProps,
|
||||
getItemProps,
|
||||
} = useCombobox({
|
||||
items,
|
||||
items: options,
|
||||
itemToString,
|
||||
inputValue,
|
||||
selectedItem: null,
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
const { type } = actionAndChanges;
|
||||
let { changes } = actionAndChanges;
|
||||
const menuBeingOpened = state.isOpen === false && changes.isOpen === true;
|
||||
|
||||
// Reset the input value when the menu is opened. If the menu is opened due to an input change
|
||||
// then make sure we keep that.
|
||||
// This will trigger onInputValueChange to load async options
|
||||
if (menuBeingOpened && changes.inputValue === state.inputValue) {
|
||||
changes = {
|
||||
...changes,
|
||||
inputValue: '',
|
||||
};
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
|
@ -171,39 +168,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
}
|
||||
},
|
||||
|
||||
onIsOpenChange: ({ isOpen, inputValue }) => {
|
||||
if (isOpen && inputValue === '') {
|
||||
updateOptions(inputValue);
|
||||
}
|
||||
},
|
||||
|
||||
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
||||
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);
|
||||
// TODO: fix bug where if the search filtered items list is the
|
||||
// same length, but different, than the selected items (ask tobias)
|
||||
const isAllFilteredSelected = selectedItems.length === options.length - 1;
|
||||
|
||||
if (!allFilteredSelected && inputValue !== '') {
|
||||
// if every option is already selected, clear the selection.
|
||||
// otherwise, select all the options (excluding the first ALL_OTION)
|
||||
const realOptions = options.slice(1);
|
||||
let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [] : realOptions;
|
||||
|
||||
if (!isAllFilteredSelected && inputValue !== '') {
|
||||
// Select all currently filtered items and deduplicate
|
||||
newSelectedItems = [...new Set([...selectedItems, ...items.slice(1)])];
|
||||
newSelectedItems = [...new Set([...selectedItems, ...realOptions])];
|
||||
}
|
||||
|
||||
if (allFilteredSelected && inputValue !== '') {
|
||||
if (isAllFilteredSelected && inputValue !== '') {
|
||||
// Deselect all currently filtered items
|
||||
const filteredSet = new Set(items.slice(1).map((item) => item.value));
|
||||
const filteredSet = new Set(realOptions.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]));
|
||||
break;
|
||||
}
|
||||
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
|
||||
setSelectedItems(newSelectedItems);
|
||||
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
|
||||
removeSelectedItem(newSelectedItem);
|
||||
} else if (newSelectedItem) {
|
||||
addSelectedItem(newSelectedItem);
|
||||
}
|
||||
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? '');
|
||||
updateOptions(newInputValue ?? '');
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
@ -211,14 +219,14 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
},
|
||||
});
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(options, isOpen);
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled, width, minWidth, maxWidth);
|
||||
|
||||
const virtualizerOptions = {
|
||||
count: items.length,
|
||||
count: options.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) =>
|
||||
'description' in items[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
'description' in options[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
};
|
||||
|
||||
|
@ -291,13 +299,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const index = virtualRow.index;
|
||||
const item = items[index];
|
||||
const item = options[index];
|
||||
const itemProps = getItemProps({ item, index });
|
||||
const isSelected = isOptionSelected(item);
|
||||
const id = 'multicombobox-option-' + item.value.toString();
|
||||
const isAll = item.value === ALL_OPTION_VALUE;
|
||||
|
||||
// TODO: fix bug where if the search filtered items list is the
|
||||
// same length, but different, than the selected items (ask tobias)
|
||||
const allItemsSelected =
|
||||
items[0]?.value === ALL_OPTION_VALUE && selectedItems.length === items.length - 1;
|
||||
options[0]?.value === ALL_OPTION_VALUE && selectedItems.length === options.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
|
@ -321,7 +332,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
label={
|
||||
isAll
|
||||
? (item.label ?? item.value.toString()) +
|
||||
(isAll && inputValue !== '' ? ` (${items.length - 1})` : '')
|
||||
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
|
||||
: (item.label ?? item.value.toString())
|
||||
}
|
||||
description={item?.description}
|
||||
|
@ -332,7 +343,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||
);
|
||||
})}
|
||||
</ul>
|
||||
<div aria-live="polite">{items.length === 0 && <NotFoundError />}</div>
|
||||
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
|
||||
</ScrollContainer>
|
||||
)}
|
||||
</div>
|
||||
|
@ -375,7 +386,3 @@ function isComboboxOptions<T extends string | number>(
|
|||
): 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);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import { debounce } from 'lodash';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { itemFilter } from './filter';
|
||||
import { ComboboxOption } from './types';
|
||||
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
|
||||
|
||||
type AsyncOptions<T extends string | number> =
|
||||
| Array<ComboboxOption<T>>
|
||||
| ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
|
||||
|
||||
const asyncNoop = () => Promise.resolve([]);
|
||||
|
||||
/**
|
||||
* Abstracts away sync/async options for MultiCombobox (and later Combobox).
|
||||
* It also filters options based on the user's input.
|
||||
*
|
||||
* Returns:
|
||||
* - options either filtered by user's input, or from async options fn
|
||||
* - function to call when user types (to filter, or call async fn)
|
||||
* - loading and error states
|
||||
*/
|
||||
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>) {
|
||||
const isAsync = typeof rawOptions === 'function';
|
||||
|
||||
const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop);
|
||||
|
||||
const debouncedLoadOptions = useMemo(
|
||||
() =>
|
||||
debounce((searchTerm: string) => {
|
||||
return loadOptions(searchTerm)
|
||||
.then((options) => {
|
||||
setAsyncOptions(options);
|
||||
setAsyncLoading(false);
|
||||
setAsyncError(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!(error instanceof StaleResultError)) {
|
||||
setAsyncError(true);
|
||||
setAsyncLoading(false);
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading async options for Combobox', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200),
|
||||
[loadOptions]
|
||||
);
|
||||
|
||||
const [asyncOptions, setAsyncOptions] = useState<Array<ComboboxOption<T>>>([]);
|
||||
const [asyncLoading, setAsyncLoading] = useState(false);
|
||||
const [asyncError, setAsyncError] = useState(false);
|
||||
|
||||
// This hook keeps its own inputValue state (rather than accepting it as an arg) because it needs to be
|
||||
// told it for async options loading anyway.
|
||||
const [userTypedSearch, setUserTypedSearch] = useState('');
|
||||
|
||||
const updateOptions = useCallback(
|
||||
(inputValue: string) => {
|
||||
if (!isAsync) {
|
||||
setUserTypedSearch(inputValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setAsyncLoading(true);
|
||||
|
||||
debouncedLoadOptions(inputValue);
|
||||
},
|
||||
[debouncedLoadOptions, isAsync]
|
||||
);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
if (isAsync) {
|
||||
return asyncOptions;
|
||||
} else {
|
||||
return rawOptions.filter(itemFilter(userTypedSearch));
|
||||
}
|
||||
}, [rawOptions, asyncOptions, isAsync, userTypedSearch]);
|
||||
|
||||
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
|
||||
}
|
Loading…
Reference in New Issue