Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UISACQCOMP-233: add useDebouncedQuery hook to fix endless request for DynamicSelection component #834

Merged
merged 4 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
Loading