Skip to content

Commit e5d562e

Browse files
authored
Merge pull request #9523 from marmelab/reference-many-field-set-filters-debounce
Fix `useReferenceManyFieldController` does not debounce `setFilters`
2 parents ce4ab9e + 979f3dc commit e5d562e

File tree

4 files changed

+89
-6
lines changed

4 files changed

+89
-6
lines changed

docs/ReferenceManyField.md

+16
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ This example leverages [`<SingleFieldList>`](./SingleFieldList.md) to display an
111111
| `pagination` | Optional | `Element` | - | Pagination element to display pagination controls. empty by default (no pagination) |
112112
| `perPage` | Optional | `number` | 25 | Maximum number of referenced records to fetch |
113113
| `sort` | Optional | `{ field, order }` | `{ field: 'id', order: 'DESC' }` | Sort order to use when fetching the related records, passed to `getManyReference()` |
114+
| `debounce` | Optional | `number` | 500 | debounce time in ms for the `setFilters` callbacks |
114115

115116
`<ReferenceManyField>` also accepts the [common field props](./Fields.md#common-field-props), except `emptyText` (use the child `empty` prop instead).
116117

@@ -172,6 +173,21 @@ export const AuthorShow = () => (
172173
);
173174
```
174175

176+
## `debounce`
177+
178+
By default, `<ReferenceManyField>` does not refresh the data as soon as the user enters data in the filter form. Instead, it waits for half a second of user inactivity (via `lodash.debounce`) before calling the `dataProvider` on filter change. This is to prevent repeated (and useless) calls to the API.
179+
180+
You can customize the debounce duration in milliseconds - or disable it completely - by passing a `debounce` prop to the `<ReferenceManyField>` component:
181+
182+
```jsx
183+
// wait 1 seconds instead of 500 milliseconds before calling the dataProvider
184+
const PostCommentsField = () => (
185+
<ReferenceManyField debounce={1000}>
186+
...
187+
</ReferenceManyField>
188+
);
189+
```
190+
175191
## `filter`
176192

177193
You can filter the query used to populate the possible values. Use the `filter` prop for that.

packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx

+49-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import expect from 'expect';
44

55
import { testDataProvider } from '../../dataProvider/testDataProvider';
@@ -19,7 +19,7 @@ const ReferenceManyFieldController = props => {
1919
describe('useReferenceManyFieldController', () => {
2020
it('should set isLoading to true when related records are not yet fetched', async () => {
2121
const ComponentToTest = ({ isLoading }: { isLoading?: boolean }) => {
22-
return <div>isLoading: {isLoading.toString()}</div>;
22+
return <div>isLoading: {isLoading?.toString()}</div>;
2323
};
2424
const dataProvider = testDataProvider({
2525
getManyReference: () => Promise.resolve({ data: [], total: 0 }),
@@ -47,7 +47,7 @@ describe('useReferenceManyFieldController', () => {
4747

4848
it('should set isLoading to false when related records have been fetched and there are results', async () => {
4949
const ComponentToTest = ({ isLoading }: { isLoading?: boolean }) => {
50-
return <div>isLoading: {isLoading.toString()}</div>;
50+
return <div>isLoading: {isLoading?.toString()}</div>;
5151
};
5252
const dataProvider = testDataProvider({
5353
getManyReference: () =>
@@ -273,4 +273,50 @@ describe('useReferenceManyFieldController', () => {
273273
);
274274
});
275275
});
276+
277+
it('should take only last change in case of a burst of setFilters calls (case of inputs being currently edited)', async () => {
278+
let childFunction = ({ setFilters, filterValues }) => (
279+
<input
280+
aria-label="search"
281+
type="text"
282+
value={filterValues.q || ''}
283+
onChange={event => {
284+
setFilters({ q: event.target.value });
285+
}}
286+
/>
287+
);
288+
const dataProvider = testDataProvider();
289+
const getManyReference = jest.spyOn(dataProvider, 'getManyReference');
290+
render(
291+
<CoreAdminContext dataProvider={dataProvider}>
292+
<ReferenceManyFieldController
293+
resource="authors"
294+
source="id"
295+
record={{ id: 123, name: 'James Joyce' }}
296+
reference="books"
297+
target="author_id"
298+
>
299+
{childFunction}
300+
</ReferenceManyFieldController>
301+
</CoreAdminContext>
302+
);
303+
const searchInput = screen.getByLabelText('search');
304+
305+
fireEvent.change(searchInput, { target: { value: 'hel' } });
306+
fireEvent.change(searchInput, { target: { value: 'hell' } });
307+
fireEvent.change(searchInput, { target: { value: 'hello' } });
308+
309+
await waitFor(() => new Promise(resolve => setTimeout(resolve, 600)));
310+
311+
// Called twice: on load and on filter changes
312+
expect(getManyReference).toHaveBeenCalledTimes(2);
313+
expect(getManyReference).toHaveBeenCalledWith('books', {
314+
target: 'author_id',
315+
id: 123,
316+
filter: { q: 'hello' },
317+
pagination: { page: 1, perPage: 25 },
318+
sort: { field: 'id', order: 'DESC' },
319+
meta: undefined,
320+
});
321+
});
276322
});

packages/ra-core/src/controller/field/useReferenceManyFieldController.ts

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useRef } from 'react';
22
import get from 'lodash/get';
33
import isEqual from 'lodash/isEqual';
4+
import lodashDebounce from 'lodash/debounce';
45

56
import { useSafeSetState, removeEmpty } from '../../util';
67
import { useGetManyReference } from '../../dataProvider';
@@ -15,6 +16,7 @@ import { useResourceContext } from '../../core';
1516
export interface UseReferenceManyFieldControllerParams<
1617
RecordType extends RaRecord = RaRecord
1718
> {
19+
debounce?: number;
1820
filter?: any;
1921
page?: number;
2022
perPage?: number;
@@ -61,6 +63,7 @@ export const useReferenceManyFieldController = <
6163
props: UseReferenceManyFieldControllerParams<RecordType>
6264
): ListControllerResult<ReferenceRecordType> => {
6365
const {
66+
debounce = 500,
6467
reference,
6568
record,
6669
target,
@@ -128,14 +131,29 @@ export const useReferenceManyFieldController = <
128131
},
129132
[setDisplayedFilters, setFilterValues]
130133
);
131-
const setFilters = useCallback(
132-
(filters, displayedFilters) => {
134+
135+
// eslint-disable-next-line react-hooks/exhaustive-deps
136+
const debouncedSetFilters = useCallback(
137+
lodashDebounce((filters, displayedFilters) => {
133138
setFilterValues(removeEmpty(filters));
134139
setDisplayedFilters(displayedFilters);
135140
setPage(1);
136-
},
141+
}, debounce),
137142
[setDisplayedFilters, setFilterValues, setPage]
138143
);
144+
145+
const setFilters = useCallback(
146+
(filters, displayedFilters, debounce = true) => {
147+
if (debounce) {
148+
debouncedSetFilters(filters, displayedFilters);
149+
} else {
150+
setFilterValues(removeEmpty(filters));
151+
setDisplayedFilters(displayedFilters);
152+
setPage(1);
153+
}
154+
},
155+
[setDisplayedFilters, setFilterValues, setPage, debouncedSetFilters]
156+
);
139157
// handle filter prop change
140158
useEffect(() => {
141159
if (!isEqual(filter, filterRef.current)) {

packages/ra-ui-materialui/src/field/ReferenceManyField.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const ReferenceManyField = <
6767
) => {
6868
const {
6969
children,
70+
debounce,
7071
filter = defaultFilter,
7172
page = 1,
7273
pagination = null,
@@ -83,6 +84,7 @@ export const ReferenceManyField = <
8384
RecordType,
8485
ReferenceRecordType
8586
>({
87+
debounce,
8688
filter,
8789
page,
8890
perPage,
@@ -108,6 +110,7 @@ export interface ReferenceManyFieldProps<
108110
RecordType extends Record<string, any> = Record<string, any>
109111
> extends FieldProps<RecordType> {
110112
children: ReactNode;
113+
debounce?: number;
111114
filter?: FilterPayload;
112115
page?: number;
113116
pagination?: ReactElement;

0 commit comments

Comments
 (0)