diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 9f81eb1fd72..73526a825a7 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -1,13 +1,15 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Chance } from 'chance'; -import React, { ComponentProps, useEffect, useState } from 'react'; +import React, { ComponentProps, useCallback, useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { Alert } from '../Alert/Alert'; import { Divider } from '../Divider/Divider'; import { Field } from '../Forms/Field'; -import { Select } from '../Select/Select'; +import { Select, AsyncSelect } from '../Select/Select'; import { Combobox, ComboboxOption } from './Combobox'; @@ -112,6 +114,10 @@ const SelectComparisonStory: StoryFn = (args) => { const [comboboxValue, setComboboxValue] = useState(args.value); const theme = useTheme2(); + if (typeof args.options === 'function') { + throw new Error('This story does not support async options'); + } + return (
@@ -248,6 +254,82 @@ export const CustomValue: StoryObj = { }, }; +const AsyncStory: StoryFn = (args) => { + // Combobox + const [selectedOption, setSelectedOption] = useState | null>(null); + + // AsyncSelect + const [asyncSelectValue, setAsyncSelectValue] = useState | null>(null); + + // This simulates a kind of search API call + const loadOptionsWithLabels = useCallback((inputValue: string) => { + console.info(`Load options called with value '${inputValue}' `); + return fakeSearchAPI(`http://example.com/search?query=${inputValue}`); + }, []); + + const loadOptionsOnlyValues = useCallback((inputValue: string) => { + return fakeSearchAPI(`http://example.com/search?query=${inputValue}`).then((options) => + options.map((opt) => ({ value: opt.label! })) + ); + }, []); + + return ( + <> + + { + action('onChange')(val); + setSelectedOption(val); + }} + createCustomValue={args.createCustomValue} + /> + + + + { + action('onChange')(val); + setSelectedOption(val); + }} + createCustomValue={args.createCustomValue} + /> + + + + { + action('onChange')(val); + setAsyncSelectValue(val); + }} + /> + + + ); +}; + +export const Async: StoryObj = { + render: AsyncStory, +}; + export const ComparisonToSelect: StoryObj = { args: { numberOfOptions: 100, @@ -270,3 +352,28 @@ function InDevDecorator(Story: React.ElementType) {
); } + +let fakeApiOptions: Array>; +async function fakeSearchAPI(urlString: string): Promise>> { + const searchParams = new URL(urlString).searchParams; + + if (!fakeApiOptions) { + fakeApiOptions = await generateOptions(1000); + } + + const searchQuery = searchParams.get('query')?.toLowerCase(); + + if (!searchQuery || searchQuery.length === 0) { + return Promise.resolve(fakeApiOptions.slice(0, 10)); + } + + const filteredOptions = Promise.resolve( + fakeApiOptions.filter((opt) => opt.label?.toLowerCase().includes(searchQuery)) + ); + + const delay = searchQuery.length % 2 === 0 ? 200 : 1000; + + return new Promise>>((resolve) => { + setTimeout(() => resolve(filteredOptions), delay); + }); +} diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index 46b7d48a404..56a26d8ed55 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Combobox, ComboboxOption } from './Combobox'; @@ -102,9 +102,7 @@ describe('Combobox', () => { await userEvent.keyboard('{Enter}'); expect(screen.getByDisplayValue('custom value')).toBeInTheDocument(); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ label: 'custom value', value: 'custom value' }) - ); + expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'custom value' })); }); it('should proivde custom string when all options are numbers', async () => { @@ -132,4 +130,102 @@ describe('Combobox', () => { expect(typeof onChangeHandler.mock.calls[1][0].value === 'number').toBeTruthy(); }); }); + + describe('async', () => { + let user: ReturnType; + + beforeAll(() => { + user = userEvent.setup({ delay: null }); + }); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + // 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); + + 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]); + expect(screen.getByDisplayValue('Option 3')).toBeInTheDocument(); + }); + + it('should ignore late responses', async () => { + const asyncOptions = jest.fn(async (searchTerm: string) => { + if (searchTerm === 'a') { + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 1000)); + } else if (searchTerm === 'ab') { + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'second' }]), 200)); + } + return Promise.resolve([]); + }); + + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); // First request + + await user.keyboard('ab'); // Second request + jest.advanceTimersByTime(210); // Resolve the second request + + let item: HTMLElement | null = await screen.findByRole('option', { name: 'second' }); + let firstItem = screen.queryByRole('option', { name: 'first' }); + + expect(item).toBeInTheDocument(); + expect(firstItem).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1100); // Resolve the first request + }); + + item = screen.queryByRole('option', { name: 'first' }); + firstItem = screen.queryByRole('option', { name: 'second' }); + + expect(item).not.toBeInTheDocument(); + expect(firstItem).toBeInTheDocument(); + }); + + it('should allow custom value while async is being run', async () => { + const asyncOptions = jest.fn(async () => { + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 2000)); + }); + + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await act(async () => { + await user.type(input, 'fir'); + jest.advanceTimersByTime(500); // Custom value while typing + }); + + const customItem = screen.queryByRole('option', { name: 'fir Create custom value' }); + + expect(customItem).toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index d38df728201..54d0d552b53 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -11,20 +11,27 @@ import { Input, Props as InputProps } from '../Input/Input'; import { getComboboxStyles } from './getComboboxStyles'; import { estimateSize, useComboboxFloat } from './useComboboxFloat'; +import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall'; export type ComboboxOption = { - label: string; + label?: string; value: T; description?: string; }; +// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async), +// then the onChange handler emits ComboboxOption with the label as non-undefined. interface ComboboxBaseProps extends Omit { isClearable?: boolean; createCustomValue?: boolean; - options: Array>; + options: Array> | ((inputValue: string) => Promise>>); onChange: (option: ComboboxOption | null) => void; - value: T | null; + /** + * Most consumers should pass value in as a scalar string | number. However, sometimes with Async because we don't + * have the full options loaded to match the value to, consumers may also pass in an Option with a label to display. + */ + value: T | ComboboxOption | null; /** * Defaults to 100%. Number is a multiple of 8px. 'auto' will size the input to the content. * */ @@ -45,7 +52,7 @@ type AutoSizeConditionals = type ComboboxProps = ComboboxBaseProps & AutoSizeConditionals; -function itemToString(item: ComboboxOption | null) { +function itemToString(item: ComboboxOption | null) { return item?.label ?? item?.value.toString() ?? ''; } @@ -61,6 +68,8 @@ function itemFilter(inputValue: string) { }; } +const asyncNoop = () => Promise.resolve([]); + /** * A performant Select replacement. * @@ -69,7 +78,7 @@ function itemFilter(inputValue: string) { export const Combobox = ({ options, onChange, - value, + value: valueProp, isClearable = false, createCustomValue = false, id, @@ -77,9 +86,21 @@ export const Combobox = ({ 'aria-labelledby': ariaLabelledBy, ...restProps }: ComboboxProps) => { - const [items, setItems] = useState(options); + // Value can be an actual scalar Value (string or number), or an Option (value + label), so + // get a consistent Value from it + const value = typeof valueProp === 'object' ? valueProp?.value : valueProp; + + const isAsync = typeof options === 'function'; + const loadOptions = useLatestAsyncCall(isAsync ? options : asyncNoop); // loadOptions isn't called at all if not async + const [asyncLoading, setAsyncLoading] = useState(false); + + const [items, setItems] = useState(isAsync ? [] : options); const selectedItemIndex = useMemo(() => { + if (isAsync) { + return null; + } + if (value === null) { return null; } @@ -90,22 +111,15 @@ export const Combobox = ({ } return index; - }, [options, value]); + }, [options, value, isAsync]); const selectedItem = useMemo(() => { - if (selectedItemIndex !== null) { + if (selectedItemIndex !== null && !isAsync) { return options[selectedItemIndex]; } - // Custom value - if (value !== null) { - return { - label: value.toString(), - value, - }; - } - return null; - }, [selectedItemIndex, options, value]); + return typeof valueProp === 'object' ? valueProp : { value: valueProp, label: valueProp.toString() }; + }, [selectedItemIndex, isAsync, valueProp, options]); const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`; const labelId = `downshift-${useId().replace(/:/g, '--')}-label`; @@ -144,27 +158,57 @@ export const Combobox = ({ defaultHighlightedIndex: selectedItemIndex ?? 0, scrollIntoView: () => {}, onInputValueChange: ({ inputValue }) => { - const filteredItems = options.filter(itemFilter(inputValue)); - if (createCustomValue && inputValue && filteredItems.findIndex((opt) => opt.label === inputValue) === -1) { - const customValueOption: ComboboxOption = { - label: inputValue, - // @ts-ignore Type casting needed to make this work when T is a number - value: inputValue as unknown as T, - description: t('combobox.custom-value.create', 'Create custom value'), - }; + const customValueOption = + createCustomValue && + inputValue && + items.findIndex((opt) => opt.label === inputValue || opt.value === inputValue) === -1 + ? { + // Type casting needed to make this work when T is a number + value: inputValue as unknown as T, + description: t('combobox.custom-value.create', 'Create custom value'), + } + : null; + + if (isAsync) { + if (customValueOption) { + setItems([customValueOption]); + } + setAsyncLoading(true); + loadOptions(inputValue) + .then((opts) => { + setItems(customValueOption ? [customValueOption, ...opts] : opts); + setAsyncLoading(false); + }) + .catch((err) => { + if (!(err instanceof StaleResultError)) { + // TODO: handle error + setAsyncLoading(false); + } + }); - setItems([...filteredItems, customValueOption]); return; - } else { - setItems(filteredItems); } + + const filteredItems = options.filter(itemFilter(inputValue)); + + setItems(customValueOption ? [customValueOption, ...filteredItems] : filteredItems); }, + onIsOpenChange: ({ isOpen }) => { // Default to displaying all values when opening - if (isOpen) { + if (isOpen && !isAsync) { setItems(options); return; } + + if (isOpen && isAsync) { + setAsyncLoading(true); + loadOptions('').then((options) => { + setItems(options); + setAsyncLoading(false); + }); + return; + } }, onHighlightedIndexChange: ({ highlightedIndex, type }) => { if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { @@ -172,6 +216,7 @@ export const Combobox = ({ } }, }); + const { inputRef, floatingRef, floatStyles } = useComboboxFloat(items, rowVirtualizer.range, isOpen); const onBlur = useCallback(() => { @@ -215,6 +260,7 @@ export const Combobox = ({ /> } + loading={asyncLoading} {...restProps} {...getInputProps({ ref: inputRef, @@ -242,7 +288,7 @@ export const Combobox = ({ {rowVirtualizer.getVirtualItems().map((virtualRow) => { return (
  • ({ })} >
    - {items[virtualRow.index].label} + + {items[virtualRow.index].label ?? items[virtualRow.index].value} + {items[virtualRow.index].description && ( {items[virtualRow.index].description} )} diff --git a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts index 60916c13ce5..0dc912f29e4 100644 --- a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts +++ b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts @@ -55,7 +55,7 @@ export const useComboboxFloat = ( const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS); for (let i = 0; i < itemsToLookAt; i++) { - const itemLabel = items[i].label; + const itemLabel = items[i].label ?? items[i].value.toString(); longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem; } diff --git a/packages/grafana-ui/src/components/Combobox/useLatestAsyncCall.ts b/packages/grafana-ui/src/components/Combobox/useLatestAsyncCall.ts new file mode 100644 index 00000000000..3e923ac05a1 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useLatestAsyncCall.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef } from 'react'; + +type AsyncFn = (value: T) => Promise; + +/** + * Wraps an async function to ensure that only the latest call is resolved. + * Used to prevent a faster call being overwritten by an earlier slower call. + */ +export function useLatestAsyncCall(fn: AsyncFn): AsyncFn { + const latestValue = useRef(); + + const wrappedFn = useCallback( + (value: T) => { + latestValue.current = value; + + return new Promise((resolve, reject) => { + fn(value).then((result) => { + // Only resolve if the value is still the latest + if (latestValue.current === value) { + resolve(result); + } else { + reject(new StaleResultError()); + } + }); + }); + }, + [fn] + ); + + return wrappedFn; +} + +export class StaleResultError extends Error { + constructor() { + super('This result is stale and is discarded'); + this.name = 'StaleResultError'; + Object.setPrototypeOf(this, new.target.prototype); // Necessary for instanceof to work correctly + } +}