Skip to content

Commit

Permalink
UISACQCOMP-233: add useDebouncedQuery hook to fix endless request f…
Browse files Browse the repository at this point in the history
…or `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
  • Loading branch information
alisher-epam authored Nov 25, 2024
1 parent 30b44ad commit a46de8d
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 51 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 22 additions & 41 deletions lib/DynamicSelection/DynamicSelection.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<Selection
dataOptions={options}
emptyMessage={!filterValue && <FormattedMessage id="stripes-acq-components.filter.dynamic.emptyMessage" />}
emptyMessage={!searchQuery && <FormattedMessage id="stripes-acq-components.filter.dynamic.emptyMessage" />}
loading={isLoading}
loadingMessage={<Loading />}
name={name}
Expand Down
26 changes: 20 additions & 6 deletions lib/DynamicSelection/DynamicSelection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
14 changes: 10 additions & 4 deletions lib/DynamicSelectionFilter/DynamicSelectionFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
1 change: 1 addition & 0 deletions lib/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions lib/hooks/useDebouncedQuery/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useDebouncedQuery } from './useDebouncedQuery';
62 changes: 62 additions & 0 deletions lib/hooks/useDebouncedQuery/useDebouncedQuery.js
Original file line number Diff line number Diff line change
@@ -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,
};
};
82 changes: 82 additions & 0 deletions lib/hooks/useDebouncedQuery/useDebouncedQuery.test.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);

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);
});
});

0 comments on commit a46de8d

Please sign in to comment.