From 9d635edd0e173f732f3ca8e2e0923d79b66a0ce7 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 28 Jan 2025 13:36:59 +0000 Subject: [PATCH] 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 --- .../components/Combobox/Combobox.story.tsx | 10 +- .../Combobox/MultiCombobox.internal.story.tsx | 97 ++++++-- .../Combobox/MultiCombobox.test.tsx | 156 ++++++++++++- .../src/components/Combobox/MultiCombobox.tsx | 213 +++++++++--------- .../src/components/Combobox/useOptions.ts | 82 +++++++ 5 files changed, 429 insertions(+), 129 deletions(-) create mode 100644 packages/grafana-ui/src/components/Combobox/useOptions.ts diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 9d3bc5fbfb8..5575d21df0e 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -162,15 +162,15 @@ export const AsyncOptionsWithLabels: Story = { return ( { - onChangeAction(val); - setArgs({ value: val }); + onChange={(value: ComboboxOption | null) => { + onChangeAction(value); + setArgs({ value }); }} /> @@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = { {...dynamicArgs} onChange={(value: ComboboxOption | null) => { onChangeAction(value); - setArgs({ value: value }); + setArgs({ value }); }} /> diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 5d27d2ac594..36c368291ee 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -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 = { @@ -11,6 +13,9 @@ const meta: Meta = { 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 = ({ numberOfOptions = 1e4, ...args }) => { - const [value, setValue] = useState([]); + const [dynamicArgs, setArgs] = useArgs(); + const [options, setOptions] = useState([]); - 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 = ({ numberOfOptions = 1e4, ... return ( { - setValue(opts || []); - action('onChange')(opts); + setArgs({ value: opts }); + onChangeAction(opts); }} /> ); @@ -104,3 +105,71 @@ export const ManyOptions: StoryObj = { }, 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 ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); + }, +}; + +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 ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); + }, +}; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index 70be3c4148d..66444644ca4 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -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; + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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((resolve) => setTimeout(() => resolve(value), timeout)); +} diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 4b819ab4a28..104f638de60 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -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 extends Omit, 'value' | 'onChange'> { value?: T[] | Array>; - onChange: (items?: T[]) => void; + onChange: (option: Array>) => void; enableAllOption?: boolean; } export type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - 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(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 = (props: MultiComboboxPro } as ComboboxOption; }, [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(value, baseOptions); + }, [value, baseOptions]); const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti( selectedItems, @@ -98,48 +80,50 @@ export const MultiCombobox = (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, - - /** - * 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, - }; - }, - }); + 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, + }; + }, + }); const { getToggleButtonProps, @@ -150,12 +134,25 @@ export const MultiCombobox = (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 = (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 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 (!allFilteredSelected && inputValue !== '') { + 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 = (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 = (props: MultiComboboxPro
    {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 (
  • (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 = (props: MultiComboboxPro ); })}
-
{items.length === 0 && }
+
{options.length === 0 && }
)} @@ -375,7 +386,3 @@ function isComboboxOptions( ): value is Array> { return typeof value[0] === 'object'; } - -function getComboboxOptionsValues(optionArray: Array>) { - return optionArray.map((option) => option.value); -} diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts new file mode 100644 index 00000000000..504f3584e1d --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -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 = + | Array> + | ((inputValue: string) => Promise>>); + +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(rawOptions: AsyncOptions) { + 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>>([]); + 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 }; +}