ComboBox: Add loading state to dropdown and prefixIcon (#112967)
This commit is contained in:
@@ -6,6 +6,7 @@ import { Field } from '../Forms/Field';
|
||||
|
||||
import { Combobox } from './Combobox';
|
||||
import { ComboboxOption } from './types';
|
||||
import { DEBOUNCE_TIME_MS } from './useOptions';
|
||||
|
||||
// Mock data for the Combobox options
|
||||
const options: ComboboxOption[] = [
|
||||
@@ -613,6 +614,23 @@ describe('Combobox', () => {
|
||||
expect(onChangeHandler).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue('Option 1');
|
||||
});
|
||||
|
||||
it('shows loading message', async () => {
|
||||
const loadingMessage = 'Loading options...';
|
||||
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||
render(<Combobox options={asyncOptions} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
await act(async () => jest.advanceTimersByTime(0));
|
||||
|
||||
expect(await screen.findByText(loadingMessage)).toBeInTheDocument();
|
||||
|
||||
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
expect(screen.queryByText(loadingMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer, type Range } from '@tanstack/react-virtual';
|
||||
import { useCombobox } from 'downshift';
|
||||
import React, { useCallback, useId, useMemo } from 'react';
|
||||
import React, { ComponentProps, useCallback, useId, useMemo } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
@@ -60,6 +60,11 @@ interface ComboboxStaticProps<T extends string | number>
|
||||
* Called when the input loses focus.
|
||||
*/
|
||||
onBlur?: () => void;
|
||||
|
||||
/**
|
||||
* Icon to display at the start of the ComboBox input
|
||||
*/
|
||||
prefixIcon?: ComponentProps<typeof Icon>['name'];
|
||||
}
|
||||
|
||||
interface ClearableProps<T extends string | number> {
|
||||
@@ -137,6 +142,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
disabled,
|
||||
portalContainer,
|
||||
invalid,
|
||||
prefixIcon,
|
||||
} = props;
|
||||
|
||||
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
|
||||
@@ -376,6 +382,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
{...(isAutoSize ? { minWidth, maxWidth } : {})}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
prefix={prefixIcon && <Icon name={prefixIcon} />}
|
||||
disabled={disabled}
|
||||
invalid={invalid}
|
||||
className={styles.input}
|
||||
@@ -402,6 +409,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
>
|
||||
{isOpen && (
|
||||
<ComboboxList
|
||||
loading={loading}
|
||||
options={filteredOptions}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItems={selectedItem ? [selectedItem] : []}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { AsyncError, NotFoundError } from './MessageRows';
|
||||
import { AsyncError, LoadingOptions, NotFoundError } from './MessageRows';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||
import { isNewGroup } from './utils';
|
||||
@@ -23,6 +23,7 @@ interface ComboboxListProps<T extends string | number> {
|
||||
enableAllOption?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
error?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxList = <T extends string | number>({
|
||||
@@ -34,6 +35,7 @@ export const ComboboxList = <T extends string | number>({
|
||||
enableAllOption,
|
||||
isMultiSelect = false,
|
||||
error = false,
|
||||
loading = false,
|
||||
}: ComboboxListProps<T>) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
@@ -161,7 +163,8 @@ export const ComboboxList = <T extends string | number>({
|
||||
|
||||
<div aria-live="polite">
|
||||
{error && <AsyncError />}
|
||||
{options.length === 0 && !error && <NotFoundError />}
|
||||
{!loading && options.length === 0 && !error && <NotFoundError />}
|
||||
{loading && options.length === 0 && <LoadingOptions />}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,12 @@ export const NotFoundError = () => (
|
||||
</MessageRow>
|
||||
);
|
||||
|
||||
export const LoadingOptions = () => (
|
||||
<MessageRow>
|
||||
<Trans i18nKey="combobox.options.loading">Loading options...</Trans>
|
||||
</MessageRow>
|
||||
);
|
||||
|
||||
const MessageRow = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Box padding={2}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
||||
import { ComboboxOption } from './types';
|
||||
import { DEBOUNCE_TIME_MS } from './useOptions';
|
||||
|
||||
describe('MultiCombobox', () => {
|
||||
beforeAll(() => {
|
||||
@@ -330,7 +331,7 @@ describe('MultiCombobox', () => {
|
||||
await user.click(input);
|
||||
|
||||
// Debounce
|
||||
await act(async () => jest.advanceTimersByTime(200));
|
||||
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
expect(asyncOptions).toHaveBeenCalled();
|
||||
});
|
||||
@@ -380,10 +381,10 @@ describe('MultiCombobox', () => {
|
||||
await user.click(input);
|
||||
|
||||
await user.keyboard('a');
|
||||
act(() => jest.advanceTimersByTime(200)); // Skip debounce
|
||||
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS)); // Skip debounce
|
||||
|
||||
await user.keyboard('b');
|
||||
act(() => jest.advanceTimersByTime(200)); // Skip debounce
|
||||
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS)); // Skip debounce
|
||||
|
||||
await user.keyboard('c');
|
||||
act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored
|
||||
@@ -422,7 +423,7 @@ describe('MultiCombobox', () => {
|
||||
act(() => jest.advanceTimersByTime(10));
|
||||
|
||||
await user.keyboard('c');
|
||||
act(() => jest.advanceTimersByTime(200));
|
||||
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
const item = await screen.findByRole('option', { name: 'Option 3' });
|
||||
expect(item).toBeInTheDocument();
|
||||
@@ -439,7 +440,7 @@ describe('MultiCombobox', () => {
|
||||
await user.click(input);
|
||||
|
||||
// Debounce
|
||||
await act(async () => jest.advanceTimersByTime(200));
|
||||
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
// Click on Option 1 to deselect it (it should already be selected via value prop)
|
||||
const item = await screen.findByRole('option', { name: 'Option 1' });
|
||||
@@ -484,7 +485,7 @@ describe('MultiCombobox', () => {
|
||||
await user.click(input);
|
||||
|
||||
// Wait for async options to load
|
||||
await act(async () => jest.advanceTimersByTime(200));
|
||||
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
// Integration A should be selected (shown as pill)
|
||||
const pillRemoveButton = screen.getByRole('button', { name: 'Remove Integration A' });
|
||||
@@ -500,6 +501,23 @@ describe('MultiCombobox', () => {
|
||||
// The pill should be removed
|
||||
expect(screen.queryByRole('button', { name: 'Remove Integration A' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading message', async () => {
|
||||
const loadingMessage = 'Loading options...';
|
||||
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||
render(<MultiCombobox options={asyncOptions} value={['Option 1']} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
await act(async () => jest.advanceTimersByTime(0));
|
||||
|
||||
expect(await screen.findByText(loadingMessage)).toBeInTheDocument();
|
||||
|
||||
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
|
||||
|
||||
expect(screen.queryByText(loadingMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'data-testid': dataTestId,
|
||||
portalContainer,
|
||||
prefixIcon,
|
||||
} = props;
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
@@ -267,6 +268,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
return (
|
||||
<div className={multiStyles.container} ref={containerRef}>
|
||||
<div className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })} ref={measureRef}>
|
||||
{prefixIcon && (
|
||||
<Box marginLeft={0.5}>
|
||||
<Text color="secondary">
|
||||
<Icon name={prefixIcon} />
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<ValuePill
|
||||
@@ -347,6 +355,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
>
|
||||
{isOpen && (
|
||||
<ComboboxList
|
||||
loading={loading}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItems={selectedItems}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const getMultiComboboxStyles = (
|
||||
inputStyles.input,
|
||||
css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: theme.spacing(0.5),
|
||||
padding: theme.spacing(0.5),
|
||||
|
||||
@@ -16,6 +16,8 @@ type AsyncOptions<T extends string | number> =
|
||||
|
||||
const asyncNoop = () => Promise.resolve([]);
|
||||
|
||||
export const DEBOUNCE_TIME_MS = 200;
|
||||
|
||||
/**
|
||||
* Abstracts away sync/async options for combobox components.
|
||||
* It also filters options based on the user's input.
|
||||
@@ -49,7 +51,7 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200),
|
||||
}, DEBOUNCE_TIME_MS),
|
||||
[loadOptions]
|
||||
);
|
||||
|
||||
|
||||
@@ -4120,6 +4120,7 @@
|
||||
"description": "Use custom value"
|
||||
},
|
||||
"options": {
|
||||
"loading": "Loading options...",
|
||||
"no-found": "No options found."
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user