diff --git a/docs/ReferenceInput.md b/docs/ReferenceInput.md index 09732221638..0850ceed613 100644 --- a/docs/ReferenceInput.md +++ b/docs/ReferenceInput.md @@ -154,7 +154,7 @@ You can make the `getList()` call lazy by using the `enableGetChoices` prop. Thi !!(q && q.length >= 2)} + enableGetChoices={({ q }) => q && q.length >= 2} /> ``` diff --git a/docs_headless/astro.config.mjs b/docs_headless/astro.config.mjs index 8525251fb31..4ba1c0ebe41 100644 --- a/docs_headless/astro.config.mjs +++ b/docs_headless/astro.config.mjs @@ -176,9 +176,12 @@ export default defineConfig({ label: 'Inputs', items: [ 'inputs', + 'referenceinputbase', + 'referencearrayinputbase', 'referencemanyinputbase', 'referencemanytomanyinputbase', 'referenceoneinputbase', + 'usechoicescontext', 'useinput', ], }, diff --git a/docs_headless/src/content/docs/ReferenceArrayInputBase.md b/docs_headless/src/content/docs/ReferenceArrayInputBase.md new file mode 100644 index 00000000000..931523623f6 --- /dev/null +++ b/docs_headless/src/content/docs/ReferenceArrayInputBase.md @@ -0,0 +1,330 @@ +--- +title: "" +--- + +`` is useful for editing an array of reference values, i.e. to let users choose a list of values (usually foreign keys) from another REST endpoint. +`` is a headless component, handling only the logic. This allows to use any UI library for the render. + +## Usage + +For instance, a post record has a `tag_ids` field, which is an array of foreign keys to tags record. + +``` +┌──────────────┐ ┌────────────┐ +│ post │ │ tags │ +│--------------│ │------------│ +│ id │ ┌───│ id │ +│ title │ │ │ name │ +│ body │ │ └────────────┘ +│ tag_ids │───┘ +└──────────────┘ +``` + +To make the `tag_ids` for a `post` editable, use the following: + +```jsx +import { EditBase, ReferenceArrayInputBase, Form, useChoicesContext, useInput } from 'ra-core'; +import { TextInput } from 'my-react-admin-ui'; + +const PostEdit = () => ( + +
+ + + + + + +
+); + +const TagSelector = () => { + const { allChoices, isLoading, error, source } = useChoicesContext(); + const { field, id } = useInput({ source }); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + const handleCheckboxChange = (choiceId) => { + const currentValue = field.value || []; + const newValue = currentValue.includes(choiceId) + ? currentValue.filter(id => id !== choiceId) + : [...currentValue, choiceId]; + field.onChange(newValue); + }; + + return ( +
+ Select tags + {allChoices.map(choice => ( + + ))} +
+ ); +}; +``` + +`` requires a `source` and a `reference` prop. + +`` uses the array of foreign keys to fetch the related records. It also grabs the list of possible choices for the field. For instance, if the `PostEdit` component above is used to edit the following post: + +```js +{ + id: 1234, + title: "Lorem Ipsum", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + tag_ids: [1, 23, 4] +} +``` + +Then `` will issue the following queries: + +```js +dataProvider.getMany('tags', { ids: [1, 23, 4] }); +dataProvider.getList('tags', { + filter: {}, + sort: { field: 'id', order: 'DESC' }, + pagination: { page: 1, perPage: 25 } +}); +``` + +`` handles the data fetching and provides the choices through a [`ChoicesContext`](./useChoicesContext.md). It's up to the child components to render the selection interface. + +You can tweak how `` fetches the possible values using the `page`, `perPage`, `sort`, and `filter` props. + +## Props + +| Prop | Required | Type | Default | Description | +|--------------------|----------|---------------------------------------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| `source` | Required | `string` | - | Name of the entity property to use for the input value | +| `reference` | Required | `string` | '' | Name of the reference resource, e.g. 'tags'. | +| `children` | Required | `ReactNode` | - | The actual selection component | +| `render` | Optional | `(context) => ReactNode` | - | Function that takes the choices context and renders the selection interface | +| `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | +| `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | +| `offline` | Optional | `ReactNode` | - | What to render when there is no network connectivity when loading the record | +| `page` | Optional | `number` | 1 | The current page number | +| `perPage` | Optional | `number` | 25 | Number of suggestions to show | +| `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | +| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | How to order the list of suggestions | + +## `children` + +You can pass any component of your own as child, to render the selection interface as you wish. +You can access the choices context using the `useChoicesContext` hook. + +```tsx +import { ReferenceArrayInputBase, useChoicesContext, useInput } from 'ra-core'; + +export const CustomArraySelector = () => { + const { allChoices, isLoading, error, source } = useChoicesContext(); + const { field, id } = useInput({ source }); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
{error.toString()}
; + } + + const handleCheckboxChange = (choiceId) => { + const currentValue = field.value || []; + const newValue = currentValue.includes(choiceId) + ? currentValue.filter(id => id !== choiceId) + : [...currentValue, choiceId]; + field.onChange(newValue); + }; + + return ( +
+ Select multiple tags + {allChoices.map(choice => ( + + ))} +
+ ); +}; + +export const MyReferenceArrayInput = () => ( + + + +); +``` + +## `render` + +Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ChoicesContext` as argument. + +```jsx +export const MyReferenceArrayInput = () => ( + { + if (isLoading) { + return
Loading...
; + } + + if (error) { + return ( +
+ {error.message} +
+ ); + } + + return ( + + ); + }} + /> +); +``` + +The `render` function prop will take priority on `children` props if both are set. + +## `enableGetChoices` + +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 a search input 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 + q && q.length >= 2} +/> +``` + +## `filter` + +You can filter the query used to populate the possible values. Use the `filter` prop for that. + +```jsx + +``` + +## `offline` + +`` can display a custom message when the referenced record is missing because there is no network connectivity, thanks to the `offline` prop. + +```jsx + +``` + +`` renders the `offline` element when: + +- the referenced record is missing (no record in the `tags` table with the right `tag_ids`), and +- there is no network connectivity + +You can pass either a React element or a string to the `offline` prop: + +```jsx +No network, could not fetch data} /> + +``` + +## `perPage` + +By default, `` fetches only the first 25 values. You can extend this limit by setting the `perPage` prop. + +```jsx + +``` + +## `queryOptions` + +Use the `queryOptions` prop to pass options to the `dataProvider.getList()` query that fetches the possible choices. + +For instance, to pass [a custom `meta`](./Actions.md#meta-parameter): + +```jsx + +``` + +## `reference` + +The name of the reference resource. For instance, in a post form, if you want to edit the post tags, the reference should be "tags". + +```jsx + +``` + +`` will use the reference resource [`recordRepresentation`](./Resource.md#recordrepresentation) to display the selected record and the list of possible records. So for instance, if the `tags` resource is defined as follows: + +```jsx + +``` + +Then `` will display the tag name in the choices list. + +## `sort` + +By default, `` orders the possible values by `id` desc. + +You can change this order by setting the `sort` prop (an object with `field` and `order` properties). + +```jsx + +``` + +## `source` + +The name of the property in the record that contains the array of identifiers of the selected record. + +For instance, if a post contains a reference to tags via a `tag_ids` property: + +```js +{ + id: 456, + title: "Hello, world!", + tag_ids: [123, 456] +} +``` + +Then to display a selector for the post tags, you should call `` as follows: + +```jsx + +``` + +## Performance + +Why does `` use the `dataProvider.getMany()` method with multiple values `[id1, id2, ...]` instead of multiple `dataProvider.getOne()` calls to fetch the records for the current values? + +Because when there may be many `` for the same resource in a form (for instance when inside an ``), react-admin *aggregates* the calls to `dataProvider.getMany()` into a single one with `[id1, id2, id3, ...]`. + +This speeds up the UI and avoids hitting the API too much. \ No newline at end of file diff --git a/docs_headless/src/content/docs/ReferenceInputBase.md b/docs_headless/src/content/docs/ReferenceInputBase.md new file mode 100644 index 00000000000..bfc95ec1e3e --- /dev/null +++ b/docs_headless/src/content/docs/ReferenceInputBase.md @@ -0,0 +1,272 @@ +--- +title: "" +--- + +`` is useful for foreign-key values, for instance, to edit the `company_id` of a `contact` resource. +`` is a headless component, handling only the logic. This allows to use any UI library for the render. + +## Usage + +For instance, a contact record has a `company_id` field, which is a foreign key to a company record. + +``` +┌──────────────┐ ┌────────────┐ +│ contacts │ │ companies │ +│--------------│ │------------│ +│ id │ ┌───│ id │ +│ first_name │ │ │ name │ +│ last_name │ │ │ address │ +│ company_id │───┘ └────────────┘ +└──────────────┘ +``` + +To make the `company_id` for a `contact` editable, use the following syntax: + +```jsx +import { EditBase, ReferenceInputBase, Form, useChoicesContext, useInput } from 'ra-core'; +import { TextInput } from 'my-react-admin-ui'; + +const ContactEdit = () => ( + +
+ + + + + + + + +
+); + +const CompanySelector = () => { + const { allChoices, isLoading, error, source } = useChoicesContext(); + const { field, id } = useInput({ source }); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + return ( +
+ + +
+ ); +}; +``` + +`` requires a `source` and a `reference` prop. + +`` uses the foreign key value to fetch the related record. It also grabs the list of possible choices for the field. For instance, if the `ContactEdit` component above is used to edit the following contact: + +```js +{ + id: 123, + first_name: 'John', + last_name: 'Doe', + company_id: 456 +} +``` + +Then `` will issue the following queries: + +```js +dataProvider.getMany('companies', { ids: [456] }); +dataProvider.getList('companies', { + filter: {}, + sort: { field: 'id', order: 'DESC' }, + pagination: { page: 1, perPage: 25 } +}); +``` + +`` handles the data fetching and provides the choices through a [`ChoicesContext`](./useChoicesContext.md). It's up to the child components to render the selection interface. + +You can tweak how `` fetches the possible values using the `page`, `perPage`, `sort`, and `filter` props. + +## Props + +| Prop | Required | Type | Default | Description | +|--------------------|----------|---------------------------------------------|----------------------------------|------------------------------------------------------------------------------------------------| +| `source` | Required | `string` | - | Name of the entity property to use for the input value | +| `reference` | Required | `string` | '' | Name of the reference resource, e.g. 'companies'. | +| `children` | Required | `ReactNode` | - | The actual selection component | +| `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | +| `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | +| `page` | Optional | `number` | 1 | The current page number | +| `perPage` | Optional | `number` | 25 | Number of suggestions to show | +| `offline` | Optional | `ReactNode` | - | What to render when there is no network connectivity when loading the record | +| `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | +| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field:'id', order:'DESC' }` | How to order the list of suggestions | + +## `children` + +You can pass any component of your own as child, to render the selection interface as you wish. +You can access the choices context using the `useChoicesContext` hook. + +```tsx +import { ReferenceInputBase, useChoicesContext, useInput } from 'ra-core'; + +export const CustomSelector = () => { + const { allChoices, isPending, error, source } = useChoicesContext(); + const { field, id } = useInput({ source }); + + if (error) { + return
{error.toString()}
; + } + + return ( +
+ + +
+ ); +}; + +export const MyReferenceInput = () => ( + + + +); +``` + +## `enableGetChoices` + +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 a search input 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 + q && q.length >= 2} +/> +``` + +## `filter` + +You can filter the query used to populate the possible values. Use the `filter` prop for that. + +```jsx + +``` + +## `offline` + +`` can display a custom message when the referenced record is missing because there is no network connectivity, thanks to the `offline` prop. + +```jsx + +``` + +`` renders the `offline` element when: + +- the referenced record is missing (no record in the `users` table with the right `user_id`), and +- there is no network connectivity + +You can pass either a React element or a string to the `offline` prop: + +```jsx +No network, could not fetch data} /> + +No network, could not fetch data} +/> +``` + +## `perPage` + +By default, `` fetches only the first 25 values. You can extend this limit by setting the `perPage` prop. + +```jsx + +``` + +## `reference` + +The name of the reference resource. For instance, in a contact form, if you want to edit the contact employer, the reference should be "companies". + +```jsx + +``` + +`` will use the reference resource [`recordRepresentation`](./Resource.md#recordrepresentation) to display the selected record and the list of possible records. So for instance, if the `companies` resource is defined as follows: + +```jsx + +``` + +Then `` will display the company name in the choices list. + +## `queryOptions` + +Use the `queryOptions` prop to pass options to the `dataProvider.getList()` query that fetches the possible choices. + +For instance, to pass [a custom `meta`](./Actions.md#meta-parameter): + +```jsx + +``` + +## `sort` + +By default, `` orders the possible values by `id` desc. + +You can change this order by setting the `sort` prop (an object with `field` and `order` properties). + +```jsx + +``` + +## `source` + +The name of the property in the record that contains the identifier of the selected record. + +For instance, if a contact contains a reference to a company via a `company_id` property: + +```js +{ + id: 456, + firstName: "John", + lastName: "Doe", + company_id: 12, +} +``` + +Then to display a selector for the contact company, you should call `` as follows: + +```jsx + +``` + +## Performance + +Why does `` 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 may be many `` for the same resource in a form (for instance when inside an ``), 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. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useChoicesContext.md b/docs_headless/src/content/docs/useChoicesContext.md new file mode 100644 index 00000000000..06b2a40311a --- /dev/null +++ b/docs_headless/src/content/docs/useChoicesContext.md @@ -0,0 +1,96 @@ +--- +title: "useChoicesContext" +--- + +The [``](./ReferenceInputBase.md) and [``](./ReferenceArrayInputBase.md) components create a `ChoicesContext` to store the choices, as well as filters, pagination, sort state, and callbacks to update them. + +The `ChoicesContext` is very similar to the [`ListContext`](./useListContext.md) with the exception that it does not return a `data` property but 3 choices related properties: + +- `availableChoices`: The choices that are not selected but match the parameters (sorting, pagination and filters) +- `selectedChoices`: The selected choices. +- `allChoices`: Merge of both available and selected choices. + +## Usage + +Call `useChoicesContext` in a component, then use this component as a descendant of a `ReferenceInput` or `ReferenceArrayInput` component. + +```jsx +// in src/comments/CompanySelector.tsx +import { useChoicesContext, useInput } from 'ra-core'; + +export const CompanySelector = () => { + const { allChoices, isLoading, error, source } = useChoicesContext(); + const { field, id } = useInput({ source }); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + return ( +
+ + +
+ ); +}; + +// in src/comments/CommentCreate.js +import { CreateBase, ReferenceInputBase, Form } from 'ra-core'; +import { PostInput } from './PostInput'; + +export const EmployeeCreate = () => ( + +
+ + + + + +
+) +``` + +## Return Value + +The `useChoicesContext` hook returns an object with the following keys: + +```jsx +const { + // fetched data + allChoices, // an array of the choices records, e.g. [{ id: 123, title: 'hello world' }, { ... }], both available and selected. + availableChoices, // an array of the available choices records, e.g. [{ id: 123, title: 'hello world' }, { ... }],. + selectedChoices, // an array of the selected choices records, e.g. [{ id: 123, title: 'hello world' }, { ... }],. + total, // the total number of results for the current filters, excluding pagination. Useful to build the pagination controls, e.g. 23 + isFetching, // boolean that is true while the data is being fetched, and false once the data is fetched + isLoading, // boolean that is true until the data has been fetched for the first time + isPending, // boolean that is true until the data is available for the first time + error, // Will contain any error that occurred while fetching data + // pagination + page, // the current page. Starts at 1 + perPage, // the number of results per page. Defaults to 25 + setPage, // a callback to change the page, e.g. setPage(3) + setPerPage, // a callback to change the number of results per page, e.g. setPerPage(25) + hasPreviousPage, // boolean, true if the current page is not the first one + hasNextPage, // boolean, true if the current page is not the last one + // sorting + sort, // a sort object { field, order }, e.g. { field: 'date', order: 'DESC' } + setSort, // a callback to change the sort, e.g. setSort({ field: 'name', order: 'ASC' }) + // filtering + filter, // The permanent filter values, e.g. { title: 'lorem', nationality: 'fr' } + filterValues, // a dictionary of filter values, e.g. { title: 'lorem', nationality: 'fr' } + displayedFilters, // a dictionary of the displayed filters, e.g. { title: true, nationality: true } + setFilters, // a callback to update the filters, e.g. setFilters(filters, displayedFilters) + showFilter, // a callback to show one of the filters, e.g. showFilter('title', defaultValue) + hideFilter, // a callback to hide one of the filters, e.g. hideFilter('title') + // misc + resource, // the resource name, deduced from the location. e.g. 'posts' + refetch, // callback for fetching the list data again + source, // the name of the field containing the currently selected record(s). +} = useChoicesContext(); +``` diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx index fe78af2ad07..96f00df771f 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx @@ -6,8 +6,8 @@ import { } from './useReferenceArrayInputController'; import { ResourceContextProvider } from '../../core/ResourceContextProvider'; import { ChoicesContextProvider } from '../../form/choices/ChoicesContextProvider'; +import { ChoicesContextValue } from '../../form/choices/ChoicesContext'; import { RaRecord } from '../../types'; -import { ChoicesContextValue } from '../../form'; /** * An Input component for fields containing a list of references to another resource. diff --git a/packages/ra-core/src/controller/input/ReferenceInputBase.stories.tsx b/packages/ra-core/src/controller/input/ReferenceInputBase.stories.tsx index 9dabf015cd9..5799c6d306a 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputBase.stories.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputBase.stories.tsx @@ -138,15 +138,15 @@ const SelectInput = ( ) => { const { allChoices, error, isPending, source } = useChoicesContext(props); const { getChoiceValue, getChoiceText } = useChoices(props); - const { field } = useInput({ ...props, source }); + const { field, id } = useInput({ ...props, source }); if (error) { return
{error.message}
; } return (
- - {isPending && } {allChoices?.map(choice => (