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 (
|
return (
|
||||||
<Field
|
<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"
|
description="Search for 'break' to see an error"
|
||||||
>
|
>
|
||||||
<Combobox
|
<Combobox
|
||||||
{...args}
|
{...args}
|
||||||
{...dynamicArgs}
|
{...dynamicArgs}
|
||||||
onChange={(val: ComboboxOption | null) => {
|
onChange={(value: ComboboxOption | null) => {
|
||||||
onChangeAction(val);
|
onChangeAction(value);
|
||||||
setArgs({ value: val });
|
setArgs({ value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = {
|
||||||
{...dynamicArgs}
|
{...dynamicArgs}
|
||||||
onChange={(value: ComboboxOption | null) => {
|
onChange={(value: ComboboxOption | null) => {
|
||||||
onChangeAction(value);
|
onChangeAction(value);
|
||||||
setArgs({ value: value });
|
setArgs({ value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { action } from '@storybook/addon-actions';
|
||||||
import { useArgs, useEffect, useState } from '@storybook/preview-api';
|
import { useArgs, useEffect, useState } from '@storybook/preview-api';
|
||||||
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
|
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Field } from '../Forms/Field';
|
||||||
|
|
||||||
import { MultiCombobox } from './MultiCombobox';
|
import { MultiCombobox } from './MultiCombobox';
|
||||||
import { generateOptions } from './storyUtils';
|
import { generateOptions, fakeSearchAPI } from './storyUtils';
|
||||||
import { ComboboxOption } from './types';
|
import { ComboboxOption } from './types';
|
||||||
|
|
||||||
const meta: Meta<typeof MultiCombobox> = {
|
const meta: Meta<typeof MultiCombobox> = {
|
||||||
|
@ -11,6 +13,9 @@ const meta: Meta<typeof MultiCombobox> = {
|
||||||
component: MultiCombobox,
|
component: MultiCombobox,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadOptionsAction = action('options called');
|
||||||
|
const onChangeAction = action('onChange called');
|
||||||
|
|
||||||
const commonArgs = {
|
const commonArgs = {
|
||||||
options: [
|
options: [
|
||||||
{ label: 'wasd - 1', value: 'option1' },
|
{ label: 'wasd - 1', value: 'option1' },
|
||||||
|
@ -40,7 +45,7 @@ export const Basic: Story = {
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
action('onChange')(val);
|
onChangeAction(val);
|
||||||
setArgs({ value: val });
|
setArgs({ value: val });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -67,17 +72,14 @@ export const AutoSize: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
|
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
|
||||||
const [value, setValue] = useState<string[]>([]);
|
const [dynamicArgs, setArgs] = useArgs();
|
||||||
|
|
||||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
generateOptions(numberOfOptions).then((options) => {
|
const options = await generateOptions(numberOfOptions);
|
||||||
setIsLoading(false);
|
setOptions(options);
|
||||||
setOptions(options);
|
|
||||||
setValue([options[5].value]);
|
|
||||||
});
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, [numberOfOptions]);
|
}, [numberOfOptions]);
|
||||||
|
|
||||||
|
@ -85,12 +87,11 @@ const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...
|
||||||
return (
|
return (
|
||||||
<MultiCombobox
|
<MultiCombobox
|
||||||
{...rest}
|
{...rest}
|
||||||
loading={isLoading}
|
{...dynamicArgs}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
|
||||||
onChange={(opts) => {
|
onChange={(opts) => {
|
||||||
setValue(opts || []);
|
setArgs({ value: opts });
|
||||||
action('onChange')(opts);
|
onChangeAction(opts);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -104,3 +105,71 @@ export const ManyOptions: StoryObj<ManyOptionsArgs> = {
|
||||||
},
|
},
|
||||||
render: ManyOptionsStory,
|
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 userEvent, { UserEvent } from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
||||||
|
import { ComboboxOption } from './types';
|
||||||
|
|
||||||
describe('MultiCombobox', () => {
|
describe('MultiCombobox', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
@ -91,15 +92,18 @@ describe('MultiCombobox', () => {
|
||||||
await user.click(input);
|
await user.click(input);
|
||||||
await user.click(await screen.findByRole('option', { name: 'A' }));
|
await user.click(await screen.findByRole('option', { name: 'A' }));
|
||||||
|
|
||||||
//Second option
|
// Second option
|
||||||
await user.click(screen.getByRole('option', { name: 'C' }));
|
await user.click(screen.getByRole('option', { name: 'C' }));
|
||||||
|
|
||||||
//Deselect
|
// Deselect
|
||||||
await user.click(screen.getByRole('option', { name: 'A' }));
|
await user.click(screen.getByRole('option', { name: 'A' }));
|
||||||
|
|
||||||
expect(onChange).toHaveBeenNthCalledWith(1, [first]);
|
expect(onChange).toHaveBeenNthCalledWith(1, [{ label: 'A', value: first }]);
|
||||||
expect(onChange).toHaveBeenNthCalledWith(2, [first, third]);
|
expect(onChange).toHaveBeenNthCalledWith(2, [
|
||||||
expect(onChange).toHaveBeenNthCalledWith(3, [third]);
|
{ 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 () => {
|
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(input);
|
||||||
await user.click(await screen.findByText('All'));
|
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 () => {
|
it('should deselect all option', async () => {
|
||||||
|
@ -157,4 +165,138 @@ describe('MultiCombobox', () => {
|
||||||
expect(onChange).toHaveBeenCalledWith([]);
|
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 { OptionListItem } from './OptionListItem';
|
||||||
import { SuffixIcon } from './SuffixIcon';
|
import { SuffixIcon } from './SuffixIcon';
|
||||||
import { ValuePill } from './ValuePill';
|
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 { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||||
import { useComboboxFloat } from './useComboboxFloat';
|
import { useComboboxFloat } from './useComboboxFloat';
|
||||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||||
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
|
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
|
||||||
|
import { useOptions } from './useOptions';
|
||||||
|
|
||||||
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?: T[] | Array<ComboboxOption<T>>;
|
value?: T[] | Array<ComboboxOption<T>>;
|
||||||
onChange: (items?: T[]) => void;
|
onChange: (option: Array<ComboboxOption<T>>) => void;
|
||||||
enableAllOption?: boolean;
|
enableAllOption?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export 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 {
|
const { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth } = props;
|
||||||
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 styles = useStyles2(getComboboxStyles);
|
const styles = useStyles2(getComboboxStyles);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
@ -73,19 +52,22 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||||
} as ComboboxOption<T>;
|
} as ComboboxOption<T>;
|
||||||
}, [inputValue]);
|
}, [inputValue]);
|
||||||
|
|
||||||
const baseItems = useMemo(() => {
|
// Handle async options and the 'All' option
|
||||||
return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options;
|
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options);
|
||||||
}, [options, enableAllOption, allOptionItem, isAsync]);
|
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 selectedItems = useMemo(() => {
|
||||||
const newItems = baseItems.filter(itemFilter(inputValue));
|
if (!value) {
|
||||||
|
|
||||||
if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return newItems;
|
return getSelectedItemsFromValue<T>(value, baseOptions);
|
||||||
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
|
}, [value, baseOptions]);
|
||||||
|
|
||||||
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
|
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
|
||||||
selectedItems,
|
selectedItems,
|
||||||
|
@ -98,48 +80,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||||
[selectedItems]
|
[selectedItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({
|
const { getSelectedItemProps, getDropdownProps, setSelectedItems, addSelectedItem, removeSelectedItem } =
|
||||||
selectedItems, //initally selected items,
|
useMultipleSelection({
|
||||||
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
|
selectedItems, // initally selected items,
|
||||||
switch (type) {
|
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
|
||||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
switch (type) {
|
||||||
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
if (newSelectedItems) {
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem:
|
||||||
}
|
case useMultipleSelection.stateChangeTypes.FunctionSetSelectedItems:
|
||||||
break;
|
// Unclear why newSelectedItems would be undefined, but this seems logical
|
||||||
|
onChange(newSelectedItems ?? []);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
stateReducer: (state, actionAndChanges) => {
|
stateReducer: (state, actionAndChanges) => {
|
||||||
const { changes } = actionAndChanges;
|
const { changes } = actionAndChanges;
|
||||||
return {
|
return {
|
||||||
...changes,
|
...changes,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Fix Hack!
|
* TODO: Fix Hack!
|
||||||
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
|
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
|
||||||
* of breaking keyboard navigation.
|
* of breaking keyboard navigation.
|
||||||
*
|
*
|
||||||
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
|
* 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.
|
* 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.
|
* This only seems to happen when you deselect the last item in the selectedItems list.
|
||||||
*
|
*
|
||||||
* Check out:
|
* Check out:
|
||||||
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
|
* - 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
|
* - 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)
|
* 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.
|
* and prevents the if statement in useMultipleSelection from focusing anything.
|
||||||
*/
|
*/
|
||||||
activeIndex: -999,
|
activeIndex: -999,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getToggleButtonProps,
|
getToggleButtonProps,
|
||||||
|
@ -150,12 +134,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||||
getInputProps,
|
getInputProps,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
} = useCombobox({
|
} = useCombobox({
|
||||||
items,
|
items: options,
|
||||||
itemToString,
|
itemToString,
|
||||||
inputValue,
|
inputValue,
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
stateReducer: (state, actionAndChanges) => {
|
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) {
|
switch (type) {
|
||||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||||
case useCombobox.stateChangeTypes.ItemClick:
|
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 }) => {
|
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:
|
||||||
// Handle All functionality
|
// Handle All functionality
|
||||||
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
|
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
|
||||||
const allFilteredSelected = selectedItems.length === items.length - 1;
|
// TODO: fix bug where if the search filtered items list is the
|
||||||
let newSelectedItems = allFilteredSelected && inputValue === '' ? [] : baseItems.slice(1);
|
// 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
|
// 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
|
// 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));
|
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
setSelectedItems(newSelectedItems);
|
||||||
break;
|
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
|
||||||
}
|
removeSelectedItem(newSelectedItem);
|
||||||
if (newSelectedItem) {
|
} else if (newSelectedItem) {
|
||||||
if (!isOptionSelected(newSelectedItem)) {
|
addSelectedItem(newSelectedItem);
|
||||||
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case useCombobox.stateChangeTypes.InputChange:
|
case useCombobox.stateChangeTypes.InputChange:
|
||||||
setInputValue(newInputValue ?? '');
|
setInputValue(newInputValue ?? '');
|
||||||
|
updateOptions(newInputValue ?? '');
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled, width, minWidth, maxWidth);
|
||||||
|
|
||||||
const virtualizerOptions = {
|
const virtualizerOptions = {
|
||||||
count: items.length,
|
count: options.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: (index: number) =>
|
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,
|
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}>
|
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const index = virtualRow.index;
|
const index = virtualRow.index;
|
||||||
const item = items[index];
|
const item = options[index];
|
||||||
const itemProps = getItemProps({ item, index });
|
const itemProps = getItemProps({ item, index });
|
||||||
const isSelected = isOptionSelected(item);
|
const isSelected = isOptionSelected(item);
|
||||||
const id = 'multicombobox-option-' + item.value.toString();
|
const id = 'multicombobox-option-' + item.value.toString();
|
||||||
const isAll = item.value === ALL_OPTION_VALUE;
|
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 =
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
|
@ -321,7 +332,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||||
label={
|
label={
|
||||||
isAll
|
isAll
|
||||||
? (item.label ?? item.value.toString()) +
|
? (item.label ?? item.value.toString()) +
|
||||||
(isAll && inputValue !== '' ? ` (${items.length - 1})` : '')
|
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
|
||||||
: (item.label ?? item.value.toString())
|
: (item.label ?? item.value.toString())
|
||||||
}
|
}
|
||||||
description={item?.description}
|
description={item?.description}
|
||||||
|
@ -332,7 +343,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div aria-live="polite">{items.length === 0 && <NotFoundError />}</div>
|
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -375,7 +386,3 @@ function isComboboxOptions<T extends string | number>(
|
||||||
): value is Array<ComboboxOption<T>> {
|
): value is Array<ComboboxOption<T>> {
|
||||||
return typeof value[0] === 'object';
|
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