GrafanaUI: Fix Combobox throwing error with too many items (#102452)

This commit is contained in:
Josh Hunt
2025-03-19 13:42:59 +00:00
committed by GitHub
parent 053ee5cb1f
commit ebeef2064e
2 changed files with 115 additions and 12 deletions

View File

@ -0,0 +1,103 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useOptions } from './useOptions';
describe('useOptions', () => {
it('should handle a large number of synchronous options without throwing an error', () => {
const largeOptions = Array.from({ length: 1_000_000 }, (_, i) => ({
label: `Option ${i + 1}`,
value: `${i + 1}`,
}));
const { result } = renderHook(() => useOptions(largeOptions, false));
act(() => {
result.current.updateOptions('Option 999999');
});
expect(result.current.options).toEqual([{ label: 'Option 999999', value: '999999' }]);
});
it('should return filtered options for synchronous options', () => {
const options = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
];
const { result } = renderHook(() => useOptions(options, false));
act(() => {
result.current.updateOptions('Option 1');
});
expect(result.current.options).toEqual([{ label: 'Option 1', value: '1' }]);
});
it('should handle asynchronous options', async () => {
const asyncOptions = jest.fn().mockResolvedValue([
{ label: 'Async Option 1', value: '1' },
{ label: 'Async Option 2', value: '2' },
]);
const { result } = renderHook(() => useOptions(asyncOptions, false));
act(() => {
result.current.updateOptions('Async');
});
expect(result.current.asyncLoading).toBe(true);
await waitFor(() => expect(result.current.asyncLoading).toBe(false));
expect(result.current.options).toEqual([
{ label: 'Async Option 1', value: '1' },
{ label: 'Async Option 2', value: '2' },
]);
});
it('should add a custom value if enabled', () => {
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Carrot', value: 'carrot' },
];
const { result } = renderHook(() => useOptions(options, true));
act(() => {
result.current.updateOptions('car');
});
expect(result.current.options).toEqual([
{ label: 'car', value: 'car', description: 'Use custom value' },
{ label: 'Carrot', value: 'carrot' },
]);
});
it('should not add a custom value if it already exists', () => {
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Carrot', value: 'carrot' },
];
const { result } = renderHook(() => useOptions(options, true));
act(() => {
result.current.updateOptions('carrot');
});
expect(result.current.options).toEqual([{ label: 'Carrot', value: 'carrot' }]);
});
it('should handle errors in asynchronous options', async () => {
jest.spyOn(console, 'error').mockImplementation();
const asyncOptions = jest.fn().mockRejectedValue(new Error('Async error'));
const { result } = renderHook(() => useOptions(asyncOptions, false));
act(() => {
result.current.updateOptions('Async');
});
expect(result.current.asyncLoading).toBe(true);
await waitFor(() => expect(result.current.asyncLoading).toBe(false));
expect(result.current.asyncLoading).toBe(false);
expect(result.current.asyncError).toBe(true);
});
});

View File

@ -1,3 +1,6 @@
/* Spreading unbound arrays can be very slow or even crash the browser if used for arguments */
/* eslint no-restricted-syntax: ["error", "SpreadElement"] */
import { debounce } from 'lodash';
import { useState, useCallback, useMemo } from 'react';
@ -14,7 +17,7 @@ type AsyncOptions<T extends string | number> =
const asyncNoop = () => Promise.resolve([]);
/**
* Abstracts away sync/async options for MultiCombobox (and later Combobox).
* Abstracts away sync/async options for combobox components.
* It also filters options based on the user's input.
*
* Returns:
@ -66,14 +69,11 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
//we just focus on the value to check if the option already exists
const customValueExists = opts.some((opt) => opt.value === userTypedSearch);
if (!customValueExists) {
currentOptions = [
{
label: userTypedSearch,
value: userTypedSearch as T,
description: t('combobox.custom-value.description', 'Use custom value'),
},
...currentOptions,
];
currentOptions.unshift({
label: userTypedSearch,
value: userTypedSearch as T,
description: t('combobox.custom-value.description', 'Use custom value'),
});
}
}
return currentOptions;
@ -131,21 +131,21 @@ function sortByGroup<T extends string | number>(options: Array<ComboboxOption<T>
let currentIndex = 0;
// Reorganize options to have groups first, then undefined group
const reorganizeOptions = [];
let reorganizeOptions: Array<ComboboxOption<T>> = [];
for (const [group, groupOptions] of groupedOptions) {
if (!group) {
continue;
}
groupStartIndices.set(group, currentIndex);
reorganizeOptions.push(...groupOptions);
reorganizeOptions = reorganizeOptions.concat(groupOptions);
currentIndex += groupOptions.length;
}
const undefinedGroupOptions = groupedOptions.get(undefined);
if (undefinedGroupOptions) {
groupStartIndices.set('undefined', currentIndex);
reorganizeOptions.push(...undefinedGroupOptions);
reorganizeOptions = reorganizeOptions.concat(undefinedGroupOptions);
}
return {