diff --git a/docs/AutocompleteInput.md b/docs/AutocompleteInput.md index 7dff59887da..6a0ef23ddfa 100644 --- a/docs/AutocompleteInput.md +++ b/docs/AutocompleteInput.md @@ -76,6 +76,7 @@ The form value for the source must be the selected value, e.g. | `onCreate` | Optional | `Function` | `-` | A function called with the current filter value when users choose to create a new choice. | | `optionText` | Optional | `function` | `ReactNode` | `undefined` | `record Representation` | Field name of record to display in the suggestion item or function using the choice object as argument | | `optionValue` | Optional | `string` | `id` | Field name of record containing the value to use as input value | +| `renderInput` | Optional | `Function` | `-` | A function rendering the input element. Overrides the default `` rendering. | | `setFilter` | Optional | `Function` | `null` | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically set up when using `ReferenceInput`. | | `shouldRender Suggestions` | Optional | `Function` | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. | | `suggestionLimit` | Optional | `number` | `null` | Limits the numbers of suggestions that are shown in the dropdown list | @@ -650,6 +651,24 @@ const choices = [ **Note:** `optionValue` is only supported when the choices are provided directly via the `choices` prop. If you use `` inside a ``, the `optionValue` is always set to `id`, as the choices are records fetched from the related resource, and [records should always have an `id` field](./FAQ.md#can-i-have-custom-identifiersprimary-keys-for-my-resources). +## `renderInput` + +By default, `` renders a Material UI `` with the input label, helper text and validation errors. If you need full control over the input rendering (e.g. to display a custom component for the selected option), pass a `renderInput` function. It receives the [MUI Autocomplete `renderInput` params](https://mui.com/material-ui/api/autocomplete/#autocomplete-prop-renderInput), which you must spread on your input component: + +```jsx +import { TextField } from '@mui/material'; + + ( + + )} +/> +``` + +**Note:** When you use `renderInput`, react-admin no longer renders the label, helper text, and validation errors for you. It's up to your custom input to display them. + ## `shouldRenderSuggestions` When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain acceptable performance. `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly and might be all you need (depending on your data set). diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 459daa35f98..bebd57d2fd2 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -32,6 +32,9 @@ import { CreateItemLabelRendered, FilterSelectedOptionsFalse, GetOptionDisabled, + RenderInput, + RenderInputFieldRef, + RenderInputWithGroups, } from './AutocompleteInput.stories'; import { ReferenceArrayInput } from './ReferenceArrayInput'; import { AutocompleteArrayInput } from './AutocompleteArrayInput'; @@ -711,6 +714,44 @@ describe('', () => { }); }); + it('should allow to override the input rendering with renderInput', async () => { + render(); + const input = (await screen.findByLabelText( + 'Custom author input' + )) as HTMLInputElement; + await waitFor(() => { + expect(input.value).toBe('Leo Tolstoy'); + }); + screen.getByText('Rendered with a custom renderInput'); + fireEvent.focus(input); + await screen.findByText('Victor Hugo'); + }); + + it('should keep the form field ref when using renderInput', async () => { + render(); + const input = await screen.findByLabelText('Author'); + fireEvent.click(screen.getByText('Focus author')); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + }); + + it('should allow to render grouped options with a custom renderInput', async () => { + render(); + const input = (await screen.findByLabelText( + 'Author' + )) as HTMLInputElement; + await waitFor(() => { + expect(input.value).toBe('Leo Tolstoy'); + }); + // the selected option is rendered as a custom component + screen.getByText('Russian'); + fireEvent.focus(input); + // group headers are shown in the suggestions list + await screen.findByText('French'); + screen.getByText('English'); + }); + it('should show the suggestions on focus', async () => { render( diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index bf5d25229ed..4ec39d0b6e9 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -34,7 +34,7 @@ import { import fakeRestProvider from 'ra-data-fakerest'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import { useFormContext } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import { useState } from 'react'; import { Create, Edit } from '../detail'; @@ -1532,6 +1532,85 @@ export const WithInputProps = () => ( ); +export const RenderInput = () => ( + + ( + + )} + /> + +); + +const FocusAuthorButton = () => { + const { setFocus } = useFormContext(); + return ; +}; + +// Regression test: a custom renderInput must keep the form field ref, +// so react-hook-form can focus the input (e.g. on validation errors). +export const RenderInputFieldRef = () => ( + + } + /> + + +); + +// MUI's groupBy requires options sorted by group +const groupedChoices = [ + { id: 2, name: 'Victor Hugo', nationality: 'French' }, + { id: 4, name: 'Charles Baudelaire', nationality: 'French' }, + { id: 5, name: 'Marcel Proust', nationality: 'French' }, + { id: 3, name: 'William Shakespeare', nationality: 'English' }, + { id: 1, name: 'Leo Tolstoy', nationality: 'Russian' }, +]; + +const AuthorInputWithGroups = () => { + const authorId = useWatch({ name: 'author' }); + const selected = groupedChoices.find(choice => choice.id === authorId); + return ( + option.nationality} + renderInput={params => ( + + ) : null, + }} + /> + )} + /> + ); +}; + +// Grouped options, with the selected option rendered as a custom component (#10430) +export const RenderInputWithGroups = () => ( + + + +); + export const OutlinedNoLabel = () => ( { + if (renderInput) { + return renderInput({ + ...params, + inputProps: { + ...params.inputProps, + // Keep the form field ref so that react-hook-form can focus the input on validation errors + ref: (node: HTMLInputElement | null) => { + assignRef( + (params.inputProps as any).ref, + node + ); + assignRef(handleInputRef, node); + }, + }, + }); + } const mergedTextFieldProps = { readOnly, ...params.InputProps, @@ -901,6 +918,12 @@ export interface AutocompleteInputProps< // Source is optional as AutocompleteInput can be used inside a ReferenceInput that already defines the source source?: string; TextFieldProps?: TextFieldProps; + renderInput?: AutocompleteProps< + OptionType, + Multiple, + DisableClearable, + SupportCreate + >['renderInput']; } /** @@ -1003,6 +1026,15 @@ const areSelectedItemsEqual = ( ); }; +// Assigns a DOM node to a callback ref or a ref object. +const assignRef = (ref: React.Ref | undefined, value: T | null) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + (ref as { current: T | null }).current = value; + } +}; + const DefaultFilterToQuery = searchText => ({ q: searchText }); const defaultOffline = ;