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

[RFR] Add option to make ReferenceInput choices lazy #6013

Merged
merged 10 commits into from
Oct 5, 2021
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
27 changes: 27 additions & 0 deletions docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2073,6 +2073,21 @@ You can tweak how this component fetches the possible values using the `perPage`
```
{% endraw %}

**Tip**: `<ReferenceArrayInput>` can also used with an `<AutocompleteArrayInput>` to allow filtering the choices. By default, it will fetch the choices on mount, but you can prevent this by using the `enableGetChoices`. This prop should be a function that receives the `filterValues` as parameter and return a boolean. In order to also hide the choices when `enableGetChoices` returns `false`, you should use `shouldRenderSuggestions` on the `<AutocompleteArrayInput>`:

```jsx
<ReferenceArrayInput
label="Tags"
reference="tags"
source="tags"
enableGetChoices={({ q }) => (q ? q.length >= 2 : false)}
>
<AutocompleteArrayInput
shouldRenderSuggestions={(value: string) => value.length >= 2}
/>
</ReferenceArrayInput>
```

In addition to the `ReferenceArrayInputContext`, `<ReferenceArrayInput>` also sets up a `ListContext` providing access to the records from the reference resource in a similar fashion to that of the `<List>` component. This `ListContext` value is accessible with the [`useListContext`](./List.md#uselistcontext) hook.

`<ReferenceArrayInput>` also accepts the [common input props](./Inputs.md#common-input-props).
Expand Down Expand Up @@ -2134,6 +2149,8 @@ import { ReferenceInput, SelectInput } from 'react-admin';
| `perPage` | Optional | `number` | 25 | Number of suggestions to show |
| `reference` | Required | `string` | '' | Name of the reference resource, e.g. 'posts'. |
| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | How to order the list of suggestions |
| `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. |


`<ReferenceInput>` also accepts the [common input props](./Inputs.md#common-input-props).

Expand Down Expand Up @@ -2222,6 +2239,16 @@ The child component receives the following props from `<ReferenceInput>`:
- `setPagination`: : function to call to update the pagination of the request for possible values
- `setSort`: function to call to update the sorting of the request for possible values

**Tip** You can make the `getList()` call lazy by using the `enableGetChoices` prop. This prop should be a function that receives the `filterValues` as parameter and return a boolean. This can be useful when using an `AutocompleteInput` on a resource with a lot of data. The following example only starts fetching the options when the query has at least 2 characters:
```jsx
<ReferenceInput
source="post_id"
reference="posts"
enableGetChoices={({ q }) => q.length >= 2}>
<AutocompleteInput optionText="title" />
</ReferenceInput>
```

**Tip**: Why does `<ReferenceInput>` use the `dataProvider.getMany()` method with a single value `[id]` instead of `dataProvider.getOne()` to fetch the record for the current value? Because when there are many `<ReferenceInput>` for the same resource in a form (for instance when inside an `<ArrayInput>`), react-admin *aggregates* the calls to `dataProvider.getMany()` into a single one with `[id1, id2, ...]`. This speeds up the UI and avoids hitting the API too much.

### `<ReferenceManyToManyInput>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -924,4 +924,91 @@ describe('<ReferenceArrayInputController />', () => {
expect(children.mock.calls[0][0].resource).toEqual('posts');
expect(children.mock.calls[0][0].basePath).toEqual('/posts');
});

describe('enableGetChoices', () => {
it('should not fetch possible values using crudGetMatching on load but only when enableGetChoices returns true', async () => {
const children = jest.fn().mockReturnValue(<div />);
await new Promise(resolve => setTimeout(resolve, 100)); // empty the query deduplication in useQueryWithStore
const enableGetChoices = jest.fn().mockImplementation(({ q }) => {
return q ? q.length > 2 : false;
});
const { dispatch } = renderWithRedux(
<Form
onSubmit={jest.fn()}
render={() => (
<ReferenceArrayInputController
{...defaultProps}
allowEmpty
enableGetChoices={enableGetChoices}
>
{children}
</ReferenceArrayInputController>
)}
/>,
{ admin: { resources: { tags: { data: {} } } } }
);

// not call on start
await waitFor(() => {
expect(dispatch).not.toHaveBeenCalled();
});
expect(enableGetChoices).toHaveBeenCalledWith({ q: '' });

const { setFilter } = children.mock.calls[0][0];
setFilter('hello world');

await waitFor(() => {
expect(dispatch).toHaveBeenCalledTimes(5);
});
expect(dispatch.mock.calls[0][0]).toEqual({
type: CRUD_GET_MATCHING,
meta: {
relatedTo: 'posts@tag_ids',
resource: 'tags',
},
payload: {
pagination: {
page: 1,
perPage: 25,
},
sort: {
field: 'id',
order: 'DESC',
},
filter: { q: 'hello world' },
},
});
expect(enableGetChoices).toHaveBeenCalledWith({ q: 'hello world' });
});

it('should fetch current value using getMany even if enableGetChoices is returning false', async () => {
const children = jest.fn(() => <div />);

const { dispatch } = renderWithRedux(
<Form
onSubmit={jest.fn()}
render={() => (
<ReferenceArrayInputController
{...defaultProps}
input={{ value: [5, 6] }}
enableGetChoices={() => false}
>
{children}
</ReferenceArrayInputController>
)}
/>,
{ admin: { resources: { tags: { data: { 5: {}, 6: {} } } } } }
);
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith({
type: CRUD_GET_MANY,
meta: {
resource: 'tags',
},
payload: { ids: [5, 6] },
});
});
expect(dispatch).toHaveBeenCalledTimes(5);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const ReferenceArrayInputController = ({
resource,
sort = { field: 'id', order: 'DESC' },
source,
enableGetChoices,
}: ReferenceArrayInputControllerProps) => {
const { setFilter, ...controllerProps } = useReferenceArrayInputController({
basePath,
Expand All @@ -105,6 +106,7 @@ const ReferenceArrayInputController = ({
reference,
resource,
source,
enableGetChoices,
});

// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -147,6 +149,7 @@ interface ReferenceArrayInputControllerProps {
resource: string;
sort?: SortPayload;
source: string;
enableGetChoices?: (filters: any) => boolean;
}

export default ReferenceArrayInputController as ComponentType<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,63 @@ describe('<ReferenceInputController />', () => {
});
});
});

describe('enableGetChoices', () => {
it('should not fetch possible values using getList on load but only when enableGetChoices returns true', async () => {
const children = jest.fn().mockReturnValue(<p>child</p>);
const enableGetChoices = jest.fn().mockImplementation(({ q }) => {
return q.length > 2;
});
const { dispatch } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<ReferenceInputController
{...defaultProps}
enableGetChoices={enableGetChoices}
>
{children}
</ReferenceInputController>
</DataProviderContext.Provider>
);

// not call on start
await waitFor(() => {
expect(dispatch).not.toHaveBeenCalled();
});
expect(enableGetChoices).toHaveBeenCalledWith({ q: '' });

const { setFilter } = children.mock.calls[0][0];
setFilter('hello world');

await waitFor(() => {
expect(dispatch).toHaveBeenCalledTimes(5);
});
expect(enableGetChoices).toHaveBeenCalledWith({ q: 'hello world' });
});

it('should fetch current value using getMany even if enableGetChoices is returning false', async () => {
const children = jest.fn().mockReturnValue(<p>child</p>);
const { dispatch } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<ReferenceInputController
{...{
...defaultProps,
input: { value: 1 } as any,
enableGetChoices: () => false,
}}
>
{children}
</ReferenceInputController>
</DataProviderContext.Provider>
);

await waitFor(() => {
expect(dispatch).toBeCalledTimes(5); // 0 for getList, 5 for getMany
expect(dispatch.mock.calls[0][0]).toEqual({
type: 'RA/CRUD_GET_MANY',
payload: { ids: [1] },
meta: { resource: 'posts' },
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ReferenceInputControllerProps {
sort?: SortPayload;
source: string;
onChange: () => void;
enableGetChoices?: (filters: any) => boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const useReferenceArrayInputController = (
options,
reference,
source,
enableGetChoices,
} = props;
const resource = useResourceContext(props);
const translate = useTranslate();
Expand Down Expand Up @@ -258,6 +259,9 @@ export const useReferenceArrayInputController = (
// filter out not found references - happens when the dataProvider doesn't guarantee referential integrity
const finalReferenceRecords = referenceRecords.filter(Boolean);

const isGetMatchingEnabled = enableGetChoices
? enableGetChoices(finalFilter)
: true;
const {
data: matchingReferences,
ids: matchingReferencesIds,
Expand All @@ -271,6 +275,8 @@ export const useReferenceArrayInputController = (
source,
resource,
options
? { ...options, enabled: isGetMatchingEnabled }
: { enabled: isGetMatchingEnabled }
);

// We merge the currently selected records with the matching ones, otherwise
Expand Down Expand Up @@ -363,6 +369,7 @@ export interface UseReferenceArrayInputOptions {
resource?: string;
sort?: SortPayload;
source: string;
enableGetChoices?: (filters: any) => boolean;
}

const defaultFilterToQuery = searchText => ({ q: searchText });
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const useReferenceInputController = (
reference,
filterToQuery,
sort: sortOverride,
enableGetChoices,
} = props;
const resource = useResourceContext(props);
const translate = useTranslate();
Expand Down Expand Up @@ -123,7 +124,10 @@ export const useReferenceInputController = (
loading: possibleValuesLoading,
error: possibleValuesError,
refetch: refetchGetList,
} = useGetList(reference, pagination, sort, filterValues);
} = useGetList(reference, pagination, sort, filterValues, {
action: 'CUSTOM_QUERY',
enabled: enableGetChoices ? enableGetChoices(filterValues) : true,
});

// fetch current value
const {
Expand Down Expand Up @@ -268,4 +272,5 @@ interface Option {
resource?: string;
sort?: SortPayload;
source: string;
enableGetChoices?: (filters: any) => boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ReferenceArrayInputProps extends InputProps {
label?: string;
reference: string;
resource?: string;
enableGetChoices?: (filters: any) => boolean;
[key: string]: any;
}

Expand Down
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/src/input/ReferenceInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export interface ReferenceInputProps extends InputProps {
// @deprecated
referenceSource?: (resource: string, source: string) => string;
resource?: string;
enableGetChoices?: (filters: any) => boolean;
[key: string]: any;
}

Expand Down