Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/AutocompleteInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<TextField>` 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 |
Expand Down Expand Up @@ -650,6 +651,24 @@ const choices = [

**Note:** `optionValue` is only supported when the choices are provided directly via the `choices` prop. If you use `<AutocompleteInput>` inside a `<ReferenceInput>`, 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, `<AutocompleteInput>` renders a Material UI `<TextField>` 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';

<AutocompleteInput
source="author_id"
choices={choices}
renderInput={params => (
<TextField {...params} label="Author" variant="outlined" />
)}
/>
```

**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).
Expand Down
41 changes: 41 additions & 0 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
CreateItemLabelRendered,
FilterSelectedOptionsFalse,
GetOptionDisabled,
RenderInput,
RenderInputFieldRef,
RenderInputWithGroups,
} from './AutocompleteInput.stories';
import { ReferenceArrayInput } from './ReferenceArrayInput';
import { AutocompleteArrayInput } from './AutocompleteArrayInput';
Expand Down Expand Up @@ -711,6 +714,44 @@ describe('<AutocompleteInput />', () => {
});
});

it('should allow to override the input rendering with renderInput', async () => {
render(<RenderInput />);
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(<RenderInputFieldRef />);
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(<RenderInputWithGroups />);
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(
<AdminContext dataProvider={testDataProvider()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1532,6 +1532,85 @@ export const WithInputProps = () => (
</Wrapper>
);

export const RenderInput = () => (
<Wrapper>
<AutocompleteInput
source="author"
choices={defaultChoices}
renderInput={params => (
<TextField
{...params}
label="Custom author input"
helperText="Rendered with a custom renderInput"
variant="outlined"
/>
)}
/>
</Wrapper>
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the original issue was about showing option groups, I think we should demonstrate this is possible with another story

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a RenderInputWithGroups story: grouped options with the selected option shown as a custom component (the use case from #10430), plus a test for it.

const FocusAuthorButton = () => {
const { setFocus } = useFormContext();
return <Button onClick={() => setFocus('author')}>Focus author</Button>;
};

// 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 = () => (
<Wrapper>
<AutocompleteInput
source="author"
choices={defaultChoices}
renderInput={params => <TextField {...params} label="Author" />}
/>
<FocusAuthorButton />
</Wrapper>
);

// 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 (
<AutocompleteInput
source="author"
choices={groupedChoices}
groupBy={option => option.nationality}
renderInput={params => (
<TextField
{...params}
label="Author"
InputProps={{
...params.InputProps,
startAdornment: selected ? (
<Chip
size="small"
label={selected.nationality}
sx={{ mr: 1 }}
/>
) : null,
}}
/>
)}
/>
);
};

// Grouped options, with the selected option rendered as a custom component (#10430)
export const RenderInputWithGroups = () => (
<Wrapper>
<AuthorInputWithGroups />
</Wrapper>
);

export const OutlinedNoLabel = () => (
<Wrapper onSuccess={console.log}>
<AutocompleteInput
Expand Down
32 changes: 32 additions & 0 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export const AutocompleteInput = <
disabled,
readOnly,
getOptionDisabled: getOptionDisabledProp,
renderInput,
...rest
} = props;

Expand Down Expand Up @@ -720,6 +721,22 @@ If you provided a React element for the optionText prop, you must also provide t
filterSelectedOptions
disabled={disabled || readOnly}
renderInput={params => {
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,
Expand Down Expand Up @@ -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'];
}

/**
Expand Down Expand Up @@ -1003,6 +1026,15 @@ const areSelectedItemsEqual = (
);
};

// Assigns a DOM node to a callback ref or a ref object.
const assignRef = <T,>(ref: React.Ref<T> | 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 = <Offline variant="inline" />;

Expand Down