mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
ca1fd028a2
commit
9f78fd94d7
@ -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'),
|
||||
};
|
||||
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 = <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…
Reference in New Issue
Block a user