diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.test.ts b/packages/grafana-ui/src/components/Combobox/useOptions.test.ts new file mode 100644 index 00000000000..ff5f5ae61b0 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useOptions.test.ts @@ -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); + }); +}); diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index 7801fe5b007..59ff783786b 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -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 = 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(rawOptions: AsyncOptions 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(options: Array let currentIndex = 0; // Reorganize options to have groups first, then undefined group - const reorganizeOptions = []; + let reorganizeOptions: Array> = []; 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 {