From e40b19c7a65d3cf418703f543410fbdc3b28df32 Mon Sep 17 00:00:00 2001 From: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:27:14 +0000 Subject: [PATCH] Combobox: Debounce async calls (#95485) --- .../src/components/Combobox/Combobox.test.tsx | 46 ++++++++++++++++--- .../src/components/Combobox/Combobox.tsx | 31 ++++++++----- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index aadb3b65f48..462e9ca7f14 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -176,11 +176,11 @@ describe('Combobox', () => { it('should ignore late responses', async () => { const asyncOptions = jest.fn(async (searchTerm: string) => { if (searchTerm === 'a') { - return new Promise((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 1000)); + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 1500)); } else if (searchTerm === 'ab') { - return new Promise((resolve) => setTimeout(() => resolve([{ value: 'second' }]), 200)); + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'second' }]), 500)); } else if (searchTerm === 'abc') { - return new Promise((resolve) => setTimeout(() => resolve([{ value: 'third' }]), 500)); + return new Promise((resolve) => setTimeout(() => resolve([{ value: 'third' }]), 800)); } return Promise.resolve([]); }); @@ -189,8 +189,14 @@ describe('Combobox', () => { const input = screen.getByRole('combobox'); await user.click(input); - await user.keyboard('abc'); - jest.advanceTimersByTime(200); // Resolve the second request, should be ignored + await act(async () => { + await user.keyboard('a'); + jest.advanceTimersByTime(200); // Skip debounce + await user.keyboard('b'); + jest.advanceTimersByTime(200); // Skip debounce + await user.keyboard('c'); + jest.advanceTimersByTime(500); // Resolve the second request, should be ignored + }); let firstItem = screen.queryByRole('option', { name: 'first' }); let secondItem = screen.queryByRole('option', { name: 'second' }); @@ -200,7 +206,7 @@ describe('Combobox', () => { expect(secondItem).not.toBeInTheDocument(); expect(thirdItem).not.toBeInTheDocument(); - jest.advanceTimersByTime(500); // Resolve the third request, should be shown + jest.advanceTimersByTime(800); // Resolve the third request, should be shown firstItem = screen.queryByRole('option', { name: 'first' }); secondItem = screen.queryByRole('option', { name: 'second' }); @@ -210,7 +216,7 @@ describe('Combobox', () => { expect(secondItem).not.toBeInTheDocument(); expect(thirdItem).toBeInTheDocument(); - jest.advanceTimersByTime(1000); // Resolve the first request, should be ignored + jest.advanceTimersByTime(1500); // Resolve the first request, should be ignored firstItem = screen.queryByRole('option', { name: 'first' }); secondItem = screen.queryByRole('option', { name: 'second' }); @@ -223,6 +229,32 @@ describe('Combobox', () => { jest.clearAllTimers(); }); + it('should debounce requests', async () => { + const asyncSpy = jest.fn(); + const asyncOptions = jest.fn(async () => { + return new Promise(asyncSpy); + }); + + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + expect(asyncSpy).toHaveBeenCalledTimes(1); // Called on open + asyncSpy.mockClear(); + + await user.keyboard('a'); + await act(async () => jest.advanceTimersByTime(10)); + + await user.keyboard('b'); + await act(async () => jest.advanceTimersByTime(10)); + + await user.keyboard('c'); + await act(async () => jest.advanceTimersByTime(200)); + + expect(asyncSpy).toHaveBeenCalledTimes(1); // Called only for 'abc' + }); + it('should allow custom value while async is being run', async () => { const asyncOptions = jest.fn(async () => { return new Promise((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 2000)); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 7006ba47182..8204199d151 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -1,6 +1,7 @@ import { cx } from '@emotion/css'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; +import { debounce } from 'lodash'; import { useCallback, useId, useMemo, useState } from 'react'; import { useStyles2 } from '../../themes'; @@ -135,6 +136,24 @@ export const Combobox = ({ const rowVirtualizer = useVirtualizer(virtualizerOptions); + const debounceAsync = useMemo( + () => + debounce((inputValue: string, customValueOption: ComboboxOption | null) => { + loadOptions(inputValue) + .then((opts) => { + setItems(customValueOption ? [customValueOption, ...opts] : opts); + setAsyncLoading(false); + }) + .catch((err) => { + if (!(err instanceof StaleResultError)) { + // TODO: handle error + setAsyncLoading(false); + } + }); + }, 200), + [loadOptions] + ); + const { getInputProps, getMenuProps, @@ -175,17 +194,7 @@ export const Combobox = ({ setItems([customValueOption]); } setAsyncLoading(true); - loadOptions(inputValue) - .then((opts) => { - setItems(customValueOption ? [customValueOption, ...opts] : opts); - setAsyncLoading(false); - }) - .catch((err) => { - if (!(err instanceof StaleResultError)) { - // TODO: handle error - setAsyncLoading(false); - } - }); + debounceAsync(inputValue, customValueOption); return; }