diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5680d86d..739a4bc3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
* Add more reusable hooks and utilities. Refs UISACQCOMP-228.
* Move reusable version history components to the ACQ lib. Refs UISACQCOMP-230.
* Move reusable helper function to support version history functionality. Refs UISACQCOMP-232.
+* Add `useDebouncedQuery` hook to fix endless request for `DynamicSelection` component. Refs UISACQCOMP-233.
## [6.0.1](https://github.com/folio-org/stripes-acq-components/tree/v6.0.1) (2024-11-14)
[Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v6.0.0...v6.0.1)
diff --git a/lib/DynamicSelection/DynamicSelection.js b/lib/DynamicSelection/DynamicSelection.js
index 7d5f7a1f..db0468f6 100644
--- a/lib/DynamicSelection/DynamicSelection.js
+++ b/lib/DynamicSelection/DynamicSelection.js
@@ -1,13 +1,13 @@
-import { useCallback, useEffect, useState } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { debounce } from 'lodash';
import PropTypes from 'prop-types';
+import { useCallback } from 'react';
+import { FormattedMessage } from 'react-intl';
-import { Loading, Selection } from '@folio/stripes/components';
-import { useOkapiKy } from '@folio/stripes/core';
+import {
+ Loading,
+ Selection,
+} from '@folio/stripes/components';
-const LIST_ITEMS_LIMIT = 100;
-const DEBOUNCE_DELAY = 500;
+import { useDebouncedQuery } from '../hooks';
export const DynamicSelection = ({
api,
@@ -19,46 +19,27 @@ export const DynamicSelection = ({
value,
...rest
}) => {
- const ky = useOkapiKy();
- const [filterValue, setFilterValue] = useState('');
- const [options, setOptions] = useState(initialOptions);
- const [isLoading, setIsLoading] = useState();
-
- const fetchData = useCallback(debounce(async (inputValue) => {
- const searchParams = {
- query: queryBuilder(inputValue),
- limit: LIST_ITEMS_LIMIT,
- };
-
- try {
- const res = await ky.get(api, { searchParams }).json();
-
- setOptions(dataFormatter(res));
- } catch {
- setOptions([]);
- }
-
- setIsLoading(false);
- }, DEBOUNCE_DELAY), []);
-
- const onFilter = useCallback((inputValue) => {
- setIsLoading(true);
- setFilterValue(inputValue);
- fetchData(inputValue);
+ const {
+ options = initialOptions,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ } = useDebouncedQuery({
+ api,
+ dataFormatter,
+ queryBuilder,
+ });
+
+ const onFilter = useCallback((filterValue) => {
+ setSearchQuery(filterValue);
return options;
- }, [options, fetchData]);
-
- useEffect(() => {
- return () => {
- fetchData.cancel();
- };
- }, []);
+ }, [options, setSearchQuery]);
return (
}
+ emptyMessage={!searchQuery && }
loading={isLoading}
loadingMessage={}
name={name}
diff --git a/lib/DynamicSelection/DynamicSelection.test.js b/lib/DynamicSelection/DynamicSelection.test.js
index fffe5f1f..379b44cd 100644
--- a/lib/DynamicSelection/DynamicSelection.test.js
+++ b/lib/DynamicSelection/DynamicSelection.test.js
@@ -5,14 +5,20 @@ import { useOkapiKy } from '@folio/stripes/core';
import { ORDERS_API } from '../constants';
import { DynamicSelection } from './DynamicSelection';
-
-jest.mock('@folio/stripes/core', () => ({
- ...jest.requireActual('@folio/stripes/core'),
- useOkapiKy: jest.fn(),
-}));
+import { useDebouncedQuery } from '../hooks';
jest.useFakeTimers('modern');
+jest.mock('../hooks', () => ({
+ ...jest.requireActual('../hooks'),
+ useDebouncedQuery: jest.fn(() => ({
+ options: [],
+ isLoading: false,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ })),
+}));
+
const dataFormatter = ({ poLines }) => poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));
const defaultProps = {
@@ -36,9 +42,17 @@ const kyMock = {
})),
};
+const mockSetInputValue = jest.fn();
+
describe('DynamicSelection', () => {
beforeEach(() => {
useOkapiKy.mockClear().mockReturnValue(kyMock);
+ useDebouncedQuery.mockClear().mockReturnValue({
+ isLoading: false,
+ options: [{ label: '11111', value: 'poLine-1' }],
+ inputValue: '',
+ setSearchQuery: mockSetInputValue,
+ });
});
it('should call debounced fetch function when \'onFilter\' was triggered', async () => {
@@ -52,7 +66,7 @@ describe('DynamicSelection', () => {
});
await user.click(screen.getByText('stripes-components.selection.controlLabel'));
- expect(kyMock.get).toHaveBeenCalledWith(ORDERS_API, expect.objectContaining({}));
+ expect(mockSetInputValue).toHaveBeenCalledWith('1');
});
it('should call \'onChange\' when an option from list was selected', async () => {
diff --git a/lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js b/lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js
index 9c5cb6ea..563f788a 100644
--- a/lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js
+++ b/lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js
@@ -8,15 +8,21 @@ import { buildFiltersObj } from '../AcqList/utils';
import { ORDERS_API } from '../constants';
import { DynamicSelectionFilter } from './DynamicSelectionFilter';
-jest.mock('@folio/stripes/core', () => ({
- ...jest.requireActual('@folio/stripes/core'),
- useOkapiKy: jest.fn(),
-}));
jest.mock('../AcqList/utils', () => ({
...jest.requireActual('../AcqList/utils'),
buildFiltersObj: jest.fn(),
}));
+jest.mock('../hooks', () => ({
+ ...jest.requireActual('../hooks'),
+ useDebouncedQuery: jest.fn(() => ({
+ options: [{ label: '11111', value: 'poLine-1' }],
+ isLoading: false,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ })),
+}));
+
jest.useFakeTimers('modern');
const dataFormatter = ({ poLines }) => poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));
diff --git a/lib/hooks/index.js b/lib/hooks/index.js
index daf5dc20..19723f7f 100644
--- a/lib/hooks/index.js
+++ b/lib/hooks/index.js
@@ -9,6 +9,7 @@ export * from './useCampuses';
export * from './useCampusesQuery';
export * from './useCategories';
export * from './useContributorNameTypes';
+export * from './useDebouncedQuery';
export * from './useDefaultReceivingSearchSettings';
export * from './useEventEmitter';
export * from './useExchangeRateValue';
diff --git a/lib/hooks/useDebouncedQuery/index.js b/lib/hooks/useDebouncedQuery/index.js
new file mode 100644
index 00000000..6543f80a
--- /dev/null
+++ b/lib/hooks/useDebouncedQuery/index.js
@@ -0,0 +1 @@
+export { useDebouncedQuery } from './useDebouncedQuery';
diff --git a/lib/hooks/useDebouncedQuery/useDebouncedQuery.js b/lib/hooks/useDebouncedQuery/useDebouncedQuery.js
new file mode 100644
index 00000000..fa187e79
--- /dev/null
+++ b/lib/hooks/useDebouncedQuery/useDebouncedQuery.js
@@ -0,0 +1,62 @@
+import debounce from 'lodash/debounce';
+import {
+ useMemo,
+ useState,
+} from 'react';
+import { useQuery } from 'react-query';
+
+import {
+ useNamespace,
+ useOkapiKy,
+} from '@folio/stripes/core';
+
+const LIST_ITEMS_LIMIT = 100;
+const DEBOUNCE_DELAY = 500;
+const DEFAULT_DATA_FORMATTER = (data) => data;
+
+export const useDebouncedQuery = ({
+ api,
+ queryBuilder,
+ dataFormatter = DEFAULT_DATA_FORMATTER,
+ debounceDelay = DEBOUNCE_DELAY,
+ limit = LIST_ITEMS_LIMIT,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [options, setOptions] = useState([]);
+ const [namespace] = useNamespace({ key: api });
+ const ky = useOkapiKy();
+
+ const debounceSetSearchQuery = useMemo(() => {
+ return debounce((value) => setSearchQuery(value), debounceDelay);
+ }, [debounceDelay]);
+
+ const { isLoading } = useQuery({
+ queryKey: [namespace, searchQuery],
+ queryFn: async ({ signal }) => {
+ if (!searchQuery) return [];
+
+ const searchParams = {
+ query: queryBuilder(searchQuery),
+ limit,
+ };
+
+ const res = await ky.get(api, { searchParams, signal }).json();
+
+ return dataFormatter(res);
+ },
+ enabled: Boolean(searchQuery),
+ onSuccess: (data) => {
+ setOptions(data);
+ },
+ onError: () => {
+ setOptions([]);
+ },
+ });
+
+ return {
+ options,
+ isLoading,
+ searchQuery,
+ setSearchQuery: debounceSetSearchQuery,
+ };
+};
diff --git a/lib/hooks/useDebouncedQuery/useDebouncedQuery.test.js b/lib/hooks/useDebouncedQuery/useDebouncedQuery.test.js
new file mode 100644
index 00000000..f2ea128b
--- /dev/null
+++ b/lib/hooks/useDebouncedQuery/useDebouncedQuery.test.js
@@ -0,0 +1,82 @@
+import {
+ QueryClient,
+ QueryClientProvider,
+} from 'react-query';
+
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useOkapiKy } from '@folio/stripes/core';
+
+import { useDebouncedQuery } from './useDebouncedQuery';
+
+const DELAY = 300;
+const mockData = { poLines: [{ id: 'poLine-1', poLineNumber: '11111' }] };
+
+jest.useFakeTimers('modern');
+const mockDataFormatter = jest.fn(({ poLines }) => {
+ return poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));
+});
+
+const queryClient = new QueryClient();
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+describe('useDebouncedQuery', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useOkapiKy.mockReturnValue({
+ get: jest.fn(() => ({
+ json: () => Promise.resolve(mockData),
+ })),
+ });
+ });
+
+ it('should not call `dataFormatter` and return empty []', async () => {
+ const { result } = renderHook(() => useDebouncedQuery({
+ api: 'api',
+ queryBuilder: jest.fn(),
+ dataFormatter: mockDataFormatter,
+ debounceDelay: DELAY,
+ }), { wrapper });
+
+ await act(async () => {
+ await result.current.setSearchQuery('');
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(mockDataFormatter).toHaveBeenCalledTimes(0);
+ expect(result.current.options).toEqual([]);
+ });
+
+ it('should call `dataFormatter` and return options', async () => {
+ const { result } = renderHook(() => useDebouncedQuery({
+ api: 'api',
+ queryBuilder: jest.fn(),
+ dataFormatter: mockDataFormatter,
+ }), { wrapper });
+
+ await act(async () => {
+ await result.current.setSearchQuery('test');
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(mockDataFormatter).toHaveBeenCalledTimes(1);
+ expect(result.current.options).toEqual([{ label: '11111', value: 'poLine-1' }]);
+ });
+
+ it('should call default `dataFormatter` when `dataFormatter` is not present', async () => {
+ const { result } = renderHook(() => useDebouncedQuery({
+ api: 'api',
+ queryBuilder: jest.fn(),
+ }), { wrapper });
+
+ await act(async () => {
+ await result.current.setSearchQuery('test');
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(result.current.options).toEqual(mockData);
+ });
+});