Skip to content

Commit a46de8d

Browse files
authored
UISACQCOMP-233: add useDebouncedQuery hook to fix endless request for DynamicSelection component (#834)
* UIF-562: add `useDebouncedQuery` hook to fix endless request for `DynamicSelection` component * test: fix failing tests and update changelog file with correct Jira ticket number * test: add test coverages * refactor: rename hook outputs and add default formatter
1 parent 30b44ad commit a46de8d

File tree

8 files changed

+199
-51
lines changed

8 files changed

+199
-51
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Add more reusable hooks and utilities. Refs UISACQCOMP-228.
66
* Move reusable version history components to the ACQ lib. Refs UISACQCOMP-230.
77
* Move reusable helper function to support version history functionality. Refs UISACQCOMP-232.
8+
* Add `useDebouncedQuery` hook to fix endless request for `DynamicSelection` component. Refs UISACQCOMP-233.
89

910
## [6.0.1](https://github.com/folio-org/stripes-acq-components/tree/v6.0.1) (2024-11-14)
1011
[Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v6.0.0...v6.0.1)

lib/DynamicSelection/DynamicSelection.js

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { useCallback, useEffect, useState } from 'react';
2-
import { FormattedMessage } from 'react-intl';
3-
import { debounce } from 'lodash';
41
import PropTypes from 'prop-types';
2+
import { useCallback } from 'react';
3+
import { FormattedMessage } from 'react-intl';
54

6-
import { Loading, Selection } from '@folio/stripes/components';
7-
import { useOkapiKy } from '@folio/stripes/core';
5+
import {
6+
Loading,
7+
Selection,
8+
} from '@folio/stripes/components';
89

9-
const LIST_ITEMS_LIMIT = 100;
10-
const DEBOUNCE_DELAY = 500;
10+
import { useDebouncedQuery } from '../hooks';
1111

1212
export const DynamicSelection = ({
1313
api,
@@ -19,46 +19,27 @@ export const DynamicSelection = ({
1919
value,
2020
...rest
2121
}) => {
22-
const ky = useOkapiKy();
23-
const [filterValue, setFilterValue] = useState('');
24-
const [options, setOptions] = useState(initialOptions);
25-
const [isLoading, setIsLoading] = useState();
26-
27-
const fetchData = useCallback(debounce(async (inputValue) => {
28-
const searchParams = {
29-
query: queryBuilder(inputValue),
30-
limit: LIST_ITEMS_LIMIT,
31-
};
32-
33-
try {
34-
const res = await ky.get(api, { searchParams }).json();
35-
36-
setOptions(dataFormatter(res));
37-
} catch {
38-
setOptions([]);
39-
}
40-
41-
setIsLoading(false);
42-
}, DEBOUNCE_DELAY), []);
43-
44-
const onFilter = useCallback((inputValue) => {
45-
setIsLoading(true);
46-
setFilterValue(inputValue);
47-
fetchData(inputValue);
22+
const {
23+
options = initialOptions,
24+
isLoading,
25+
searchQuery,
26+
setSearchQuery,
27+
} = useDebouncedQuery({
28+
api,
29+
dataFormatter,
30+
queryBuilder,
31+
});
32+
33+
const onFilter = useCallback((filterValue) => {
34+
setSearchQuery(filterValue);
4835

4936
return options;
50-
}, [options, fetchData]);
51-
52-
useEffect(() => {
53-
return () => {
54-
fetchData.cancel();
55-
};
56-
}, []);
37+
}, [options, setSearchQuery]);
5738

5839
return (
5940
<Selection
6041
dataOptions={options}
61-
emptyMessage={!filterValue && <FormattedMessage id="stripes-acq-components.filter.dynamic.emptyMessage" />}
42+
emptyMessage={!searchQuery && <FormattedMessage id="stripes-acq-components.filter.dynamic.emptyMessage" />}
6243
loading={isLoading}
6344
loadingMessage={<Loading />}
6445
name={name}

lib/DynamicSelection/DynamicSelection.test.js

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ import { useOkapiKy } from '@folio/stripes/core';
55

66
import { ORDERS_API } from '../constants';
77
import { DynamicSelection } from './DynamicSelection';
8-
9-
jest.mock('@folio/stripes/core', () => ({
10-
...jest.requireActual('@folio/stripes/core'),
11-
useOkapiKy: jest.fn(),
12-
}));
8+
import { useDebouncedQuery } from '../hooks';
139

1410
jest.useFakeTimers('modern');
1511

12+
jest.mock('../hooks', () => ({
13+
...jest.requireActual('../hooks'),
14+
useDebouncedQuery: jest.fn(() => ({
15+
options: [],
16+
isLoading: false,
17+
searchQuery: '',
18+
setSearchQuery: jest.fn(),
19+
})),
20+
}));
21+
1622
const dataFormatter = ({ poLines }) => poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));
1723

1824
const defaultProps = {
@@ -36,9 +42,17 @@ const kyMock = {
3642
})),
3743
};
3844

45+
const mockSetInputValue = jest.fn();
46+
3947
describe('DynamicSelection', () => {
4048
beforeEach(() => {
4149
useOkapiKy.mockClear().mockReturnValue(kyMock);
50+
useDebouncedQuery.mockClear().mockReturnValue({
51+
isLoading: false,
52+
options: [{ label: '11111', value: 'poLine-1' }],
53+
inputValue: '',
54+
setSearchQuery: mockSetInputValue,
55+
});
4256
});
4357

4458
it('should call debounced fetch function when \'onFilter\' was triggered', async () => {
@@ -52,7 +66,7 @@ describe('DynamicSelection', () => {
5266
});
5367
await user.click(screen.getByText('stripes-components.selection.controlLabel'));
5468

55-
expect(kyMock.get).toHaveBeenCalledWith(ORDERS_API, expect.objectContaining({}));
69+
expect(mockSetInputValue).toHaveBeenCalledWith('1');
5670
});
5771

5872
it('should call \'onChange\' when an option from list was selected', async () => {

lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ import { buildFiltersObj } from '../AcqList/utils';
88
import { ORDERS_API } from '../constants';
99
import { DynamicSelectionFilter } from './DynamicSelectionFilter';
1010

11-
jest.mock('@folio/stripes/core', () => ({
12-
...jest.requireActual('@folio/stripes/core'),
13-
useOkapiKy: jest.fn(),
14-
}));
1511
jest.mock('../AcqList/utils', () => ({
1612
...jest.requireActual('../AcqList/utils'),
1713
buildFiltersObj: jest.fn(),
1814
}));
1915

16+
jest.mock('../hooks', () => ({
17+
...jest.requireActual('../hooks'),
18+
useDebouncedQuery: jest.fn(() => ({
19+
options: [{ label: '11111', value: 'poLine-1' }],
20+
isLoading: false,
21+
searchQuery: '',
22+
setSearchQuery: jest.fn(),
23+
})),
24+
}));
25+
2026
jest.useFakeTimers('modern');
2127

2228
const dataFormatter = ({ poLines }) => poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));

lib/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './useCampuses';
99
export * from './useCampusesQuery';
1010
export * from './useCategories';
1111
export * from './useContributorNameTypes';
12+
export * from './useDebouncedQuery';
1213
export * from './useDefaultReceivingSearchSettings';
1314
export * from './useEventEmitter';
1415
export * from './useExchangeRateValue';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useDebouncedQuery } from './useDebouncedQuery';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import debounce from 'lodash/debounce';
2+
import {
3+
useMemo,
4+
useState,
5+
} from 'react';
6+
import { useQuery } from 'react-query';
7+
8+
import {
9+
useNamespace,
10+
useOkapiKy,
11+
} from '@folio/stripes/core';
12+
13+
const LIST_ITEMS_LIMIT = 100;
14+
const DEBOUNCE_DELAY = 500;
15+
const DEFAULT_DATA_FORMATTER = (data) => data;
16+
17+
export const useDebouncedQuery = ({
18+
api,
19+
queryBuilder,
20+
dataFormatter = DEFAULT_DATA_FORMATTER,
21+
debounceDelay = DEBOUNCE_DELAY,
22+
limit = LIST_ITEMS_LIMIT,
23+
}) => {
24+
const [searchQuery, setSearchQuery] = useState('');
25+
const [options, setOptions] = useState([]);
26+
const [namespace] = useNamespace({ key: api });
27+
const ky = useOkapiKy();
28+
29+
const debounceSetSearchQuery = useMemo(() => {
30+
return debounce((value) => setSearchQuery(value), debounceDelay);
31+
}, [debounceDelay]);
32+
33+
const { isLoading } = useQuery({
34+
queryKey: [namespace, searchQuery],
35+
queryFn: async ({ signal }) => {
36+
if (!searchQuery) return [];
37+
38+
const searchParams = {
39+
query: queryBuilder(searchQuery),
40+
limit,
41+
};
42+
43+
const res = await ky.get(api, { searchParams, signal }).json();
44+
45+
return dataFormatter(res);
46+
},
47+
enabled: Boolean(searchQuery),
48+
onSuccess: (data) => {
49+
setOptions(data);
50+
},
51+
onError: () => {
52+
setOptions([]);
53+
},
54+
});
55+
56+
return {
57+
options,
58+
isLoading,
59+
searchQuery,
60+
setSearchQuery: debounceSetSearchQuery,
61+
};
62+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
QueryClient,
3+
QueryClientProvider,
4+
} from 'react-query';
5+
6+
import { renderHook, act } from '@testing-library/react-hooks';
7+
import { useOkapiKy } from '@folio/stripes/core';
8+
9+
import { useDebouncedQuery } from './useDebouncedQuery';
10+
11+
const DELAY = 300;
12+
const mockData = { poLines: [{ id: 'poLine-1', poLineNumber: '11111' }] };
13+
14+
jest.useFakeTimers('modern');
15+
const mockDataFormatter = jest.fn(({ poLines }) => {
16+
return poLines.map(({ id, poLineNumber }) => ({ label: poLineNumber, value: id }));
17+
});
18+
19+
const queryClient = new QueryClient();
20+
const wrapper = ({ children }) => (
21+
<QueryClientProvider client={queryClient}>
22+
{children}
23+
</QueryClientProvider>
24+
);
25+
26+
describe('useDebouncedQuery', () => {
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
useOkapiKy.mockReturnValue({
30+
get: jest.fn(() => ({
31+
json: () => Promise.resolve(mockData),
32+
})),
33+
});
34+
});
35+
36+
it('should not call `dataFormatter` and return empty []', async () => {
37+
const { result } = renderHook(() => useDebouncedQuery({
38+
api: 'api',
39+
queryBuilder: jest.fn(),
40+
dataFormatter: mockDataFormatter,
41+
debounceDelay: DELAY,
42+
}), { wrapper });
43+
44+
await act(async () => {
45+
await result.current.setSearchQuery('');
46+
jest.advanceTimersByTime(1500);
47+
});
48+
49+
expect(mockDataFormatter).toHaveBeenCalledTimes(0);
50+
expect(result.current.options).toEqual([]);
51+
});
52+
53+
it('should call `dataFormatter` and return options', async () => {
54+
const { result } = renderHook(() => useDebouncedQuery({
55+
api: 'api',
56+
queryBuilder: jest.fn(),
57+
dataFormatter: mockDataFormatter,
58+
}), { wrapper });
59+
60+
await act(async () => {
61+
await result.current.setSearchQuery('test');
62+
jest.advanceTimersByTime(1500);
63+
});
64+
65+
expect(mockDataFormatter).toHaveBeenCalledTimes(1);
66+
expect(result.current.options).toEqual([{ label: '11111', value: 'poLine-1' }]);
67+
});
68+
69+
it('should call default `dataFormatter` when `dataFormatter` is not present', async () => {
70+
const { result } = renderHook(() => useDebouncedQuery({
71+
api: 'api',
72+
queryBuilder: jest.fn(),
73+
}), { wrapper });
74+
75+
await act(async () => {
76+
await result.current.setSearchQuery('test');
77+
jest.advanceTimersByTime(1500);
78+
});
79+
80+
expect(result.current.options).toEqual(mockData);
81+
});
82+
});

0 commit comments

Comments
 (0)