GrafanaUI: Fix Combobox throwing error with too many items (#102452)
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { debounce } from 'lodash';
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
@@ -14,7 +17,7 @@ type AsyncOptions<T extends string | number> =
|
|||||||
const asyncNoop = () => Promise.resolve([]);
|
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.
|
* It also filters options based on the user's input.
|
||||||
*
|
*
|
||||||
* Returns:
|
* 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
|
//we just focus on the value to check if the option already exists
|
||||||
const customValueExists = opts.some((opt) => opt.value === userTypedSearch);
|
const customValueExists = opts.some((opt) => opt.value === userTypedSearch);
|
||||||
if (!customValueExists) {
|
if (!customValueExists) {
|
||||||
currentOptions = [
|
currentOptions.unshift({
|
||||||
{
|
label: userTypedSearch,
|
||||||
label: userTypedSearch,
|
value: userTypedSearch as T,
|
||||||
value: userTypedSearch as T,
|
description: t('combobox.custom-value.description', 'Use custom value'),
|
||||||
description: t('combobox.custom-value.description', 'Use custom value'),
|
});
|
||||||
},
|
|
||||||
...currentOptions,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return currentOptions;
|
return currentOptions;
|
||||||
@@ -131,21 +131,21 @@ function sortByGroup<T extends string | number>(options: Array<ComboboxOption<T>
|
|||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
|
|
||||||
// Reorganize options to have groups first, then undefined group
|
// Reorganize options to have groups first, then undefined group
|
||||||
const reorganizeOptions = [];
|
let reorganizeOptions: Array<ComboboxOption<T>> = [];
|
||||||
for (const [group, groupOptions] of groupedOptions) {
|
for (const [group, groupOptions] of groupedOptions) {
|
||||||
if (!group) {
|
if (!group) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
groupStartIndices.set(group, currentIndex);
|
groupStartIndices.set(group, currentIndex);
|
||||||
reorganizeOptions.push(...groupOptions);
|
reorganizeOptions = reorganizeOptions.concat(groupOptions);
|
||||||
currentIndex += groupOptions.length;
|
currentIndex += groupOptions.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const undefinedGroupOptions = groupedOptions.get(undefined);
|
const undefinedGroupOptions = groupedOptions.get(undefined);
|
||||||
if (undefinedGroupOptions) {
|
if (undefinedGroupOptions) {
|
||||||
groupStartIndices.set('undefined', currentIndex);
|
groupStartIndices.set('undefined', currentIndex);
|
||||||
reorganizeOptions.push(...undefinedGroupOptions);
|
reorganizeOptions = reorganizeOptions.concat(undefinedGroupOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user