diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index 537e26df1c9..b99e58a447b 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -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(); + + 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(); + }); }); }); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index b81d0e601fa..9c4de9c2d8b 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -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 * Called when the input loses focus. */ onBlur?: () => void; + + /** + * Icon to display at the start of the ComboBox input + */ + prefixIcon?: ComponentProps['name']; } interface ClearableProps { @@ -137,6 +142,7 @@ export const Combobox = (props: ComboboxProps) => 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 = (props: ComboboxProps) => {...(isAutoSize ? { minWidth, maxWidth } : {})} autoFocus={autoFocus} onBlur={onBlur} + prefix={prefixIcon && } disabled={disabled} invalid={invalid} className={styles.input} @@ -402,6 +409,7 @@ export const Combobox = (props: ComboboxProps) => > {isOpen && ( { enableAllOption?: boolean; isMultiSelect?: boolean; error?: boolean; + loading?: boolean; } export const ComboboxList = ({ @@ -34,6 +35,7 @@ export const ComboboxList = ({ enableAllOption, isMultiSelect = false, error = false, + loading = false, }: ComboboxListProps) => { const styles = useStyles2(getComboboxStyles); @@ -161,7 +163,8 @@ export const ComboboxList = ({
{error && } - {options.length === 0 && !error && } + {!loading && options.length === 0 && !error && } + {loading && options.length === 0 && }
); diff --git a/packages/grafana-ui/src/components/Combobox/MessageRows.tsx b/packages/grafana-ui/src/components/Combobox/MessageRows.tsx index a2d45162c01..46f2a5e4cc3 100644 --- a/packages/grafana-ui/src/components/Combobox/MessageRows.tsx +++ b/packages/grafana-ui/src/components/Combobox/MessageRows.tsx @@ -22,6 +22,12 @@ export const NotFoundError = () => ( ); +export const LoadingOptions = () => ( + + Loading options... + +); + const MessageRow = ({ children }: { children: ReactNode }) => { return ( diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index 572a9d865a0..2061087828d 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -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(); + + 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(); + }); }); }); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 13dd02742e4..989bf3d0e2e 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -51,6 +51,7 @@ export const MultiCombobox = (props: MultiComboboxPro 'aria-labelledby': ariaLabelledBy, 'data-testid': dataTestId, portalContainer, + prefixIcon, } = props; const styles = useStyles2(getComboboxStyles); @@ -267,6 +268,13 @@ export const MultiCombobox = (props: MultiComboboxPro return (
+ {prefixIcon && ( + + + + + + )} {visibleItems.map((item, index) => ( (props: MultiComboboxPro > {isOpen && ( = 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(rawOptions: AsyncOptions