New Select: Async functionality (#94147)

* Initial async

* Set value

* Update story

* Ignore older returned requests

* Add tests

* Update async test

* Support custom value

* Fix ignore late responses test

* Add act to test

* Fix final test

* Remove comment and fix type error

* refactor async story to look more like api call

* allow consumers to pass in a value with a label, for async

* compare story to async select

* Move 'keep latest async value' into seperate hook

* remove null fn from useLatestAsyncCall

* remove commented assertion

* move custom value to top

* before/afterAll & useRealTimers

* create a user

* no useless await

---------

Co-authored-by: Joao Silva <joao.silva@grafana.com>
Co-authored-by: joshhunt <josh@trtr.co>
pull/94807/head
Tobias Skarhed 7 months ago committed by GitHub
parent ca1fd028a2
commit 9f78fd94d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 111
      packages/grafana-ui/src/components/Combobox/Combobox.story.tsx
  2. 104
      packages/grafana-ui/src/components/Combobox/Combobox.test.tsx
  3. 112
      packages/grafana-ui/src/components/Combobox/Combobox.tsx
  4. 2
      packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts
  5. 39
      packages/grafana-ui/src/components/Combobox/useLatestAsyncCall.ts

@ -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<typeof Combobox> = (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 (
<div style={{ border: '1px solid ' + theme.colors.border.weak, padding: 16 }}>
<Field label="Combobox with default size">
@ -248,6 +254,82 @@ export const CustomValue: StoryObj<PropsAndCustomArgs> = {
},
};
const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
// Combobox
const [selectedOption, setSelectedOption] = useState<ComboboxOption<string> | null>(null);
// AsyncSelect
const [asyncSelectValue, setAsyncSelectValue] = useState<SelectableValue<string> | 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 (
<>
<Field
label="Options with labels"
description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label"
>
<Combobox
id="test-combobox-one"
placeholder="Select an option"
options={loadOptionsWithLabels}
value={selectedOption}
onChange={(val) => {
action('onChange')(val);
setSelectedOption(val);
}}
createCustomValue={args.createCustomValue}
/>
</Field>
<Field
label="Options without labels"
description="Or without labels, where consumer can just pass in a raw scalar value Value"
>
<Combobox
id="test-combobox-two"
placeholder="Select an option"
options={loadOptionsOnlyValues}
value={selectedOption?.value ?? null}
onChange={(val) => {
action('onChange')(val);
setSelectedOption(val);
}}
createCustomValue={args.createCustomValue}
/>
</Field>
<Field label="Compared to AsyncSelect">
<AsyncSelect
id="test-async-select"
placeholder="Select an option"
loadOptions={loadOptionsWithLabels}
value={asyncSelectValue}
defaultOptions
onChange={(val) => {
action('onChange')(val);
setAsyncSelectValue(val);
}}
/>
</Field>
</>
);
};
export const Async: StoryObj<PropsAndCustomArgs> = {
render: AsyncStory,
};
export const ComparisonToSelect: StoryObj<PropsAndCustomArgs> = {
args: {
numberOfOptions: 100,
@ -270,3 +352,28 @@ function InDevDecorator(Story: React.ElementType) {
</div>
);
}
let fakeApiOptions: Array<ComboboxOption<string>>;
async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> {
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<Array<ComboboxOption<string>>>((resolve) => {
setTimeout(() => resolve(filteredOptions), delay);
});
}

@ -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<typeof userEvent.setup>;
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(<Combobox options={asyncOptions} value={null} onChange={onChangeHandler} />);
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(<Combobox options={asyncOptions} value={null} 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]);
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<ComboboxOption[]>((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 1000));
} else if (searchTerm === 'ab') {
return new Promise<ComboboxOption[]>((resolve) => setTimeout(() => resolve([{ value: 'second' }]), 200));
}
return Promise.resolve([]);
});
render(<Combobox options={asyncOptions} value={null} onChange={onChangeHandler} />);
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<ComboboxOption[]>((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 2000));
});
render(<Combobox options={asyncOptions} value={null} onChange={onChangeHandler} createCustomValue />);
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();
});
});
});

@ -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<T extends string | number = string> = {
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<T extends string | number>
extends Omit<InputProps, 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange' | 'width'> {
isClearable?: boolean;
createCustomValue?: boolean;
options: Array<ComboboxOption<T>>;
options: Array<ComboboxOption<T>> | ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
onChange: (option: ComboboxOption<T> | 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<T> | 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<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals;
function itemToString(item: ComboboxOption<string | number> | null) {
function itemToString<T extends string | number>(item: ComboboxOption<T> | null) {
return item?.label ?? item?.value.toString() ?? '';
}
@ -61,6 +68,8 @@ function itemFilter<T extends string | number>(inputValue: string) {
};
}
const asyncNoop = () => Promise.resolve([]);
/**
* A performant Select replacement.
*
@ -69,7 +78,7 @@ function itemFilter<T extends string | number>(inputValue: string) {
export const Combobox = <T extends string | number>({
options,
onChange,
value,
value: valueProp,
isClearable = false,
createCustomValue = false,
id,
@ -77,9 +86,21 @@ export const Combobox = <T extends string | number>({
'aria-labelledby': ariaLabelledBy,
...restProps
}: ComboboxProps<T>) => {
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 = <T extends string | number>({
}
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 = <T extends string | number>({
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<T> = {
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'),
};
setItems([...filteredItems, customValueOption]);
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);
}
});
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 = <T extends string | number>({
}
},
});
const { inputRef, floatingRef, floatStyles } = useComboboxFloat(items, rowVirtualizer.range, isOpen);
const onBlur = useCallback(() => {
@ -215,6 +260,7 @@ export const Combobox = <T extends string | number>({
/>
</>
}
loading={asyncLoading}
{...restProps}
{...getInputProps({
ref: inputRef,
@ -242,7 +288,7 @@ export const Combobox = <T extends string | number>({
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<li
key={items[virtualRow.index].value + items[virtualRow.index].label}
key={`${items[virtualRow.index].value}-${virtualRow.index}`}
data-index={virtualRow.index}
className={cx(
styles.option,
@ -259,7 +305,9 @@ export const Combobox = <T extends string | number>({
})}
>
<div className={styles.optionBody}>
<span className={styles.optionLabel}>{items[virtualRow.index].label}</span>
<span className={styles.optionLabel}>
{items[virtualRow.index].label ?? items[virtualRow.index].value}
</span>
{items[virtualRow.index].description && (
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
)}

@ -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;
}

@ -0,0 +1,39 @@
import { useCallback, useRef } from 'react';
type AsyncFn<T, V> = (value: T) => Promise<V>;
/**
* 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<T, V>(fn: AsyncFn<T, V>): AsyncFn<T, V> {
const latestValue = useRef<T>();
const wrappedFn = useCallback(
(value: T) => {
latestValue.current = value;
return new Promise<V>((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
}
}
Loading…
Cancel
Save