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

Fix <AutoCompleteInput> should not display a "Create" option when the filter is empty #10266

Merged
merged 8 commits into from
Oct 11, 2024
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
7 changes: 2 additions & 5 deletions docs/AutocompleteInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,11 @@ If you just need to ask users for a single string to create the new option, you

## `createLabel`

When you set the `create` or `onCreate` prop, `<AutocompleteInput>` lets users create new options. By default, it renders a "Create" menu item at the bottom of the list. You can customize the label of that menu item by setting a custom translation for the `ra.action.create` key in the translation files.
When you set the `create` or `onCreate` prop, `<AutocompleteInput>` lets users create new options.
You can use the `createLabel` prop to render an additional menu item at the bottom of the list, that will only appear when the input is empty, inviting users to start typing to create a new option.

![Create Label](./img/AutocompleteInput-createLabel.png)

Or, if you want to customize it just for this `<AutocompleteInput>`, use the `createLabel` prop:

You can customize the label of that menu item by setting a custom translation for the `ra.action.create` key in the translation files.

```jsx
<AutocompleteInput
source="author"
Expand Down
79 changes: 75 additions & 4 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
InsideReferenceInputOnChange,
WithInputProps,
OnCreate,
OnCreateSlow,
} from './AutocompleteInput.stories';
import { ReferenceArrayInput } from './ReferenceArrayInput';
import { AutocompleteArrayInput } from './AutocompleteArrayInput';
Expand Down Expand Up @@ -515,7 +516,7 @@ describe('<AutocompleteInput />', () => {
];
const OptionText = () => {
const record = useRecordContext();
return <span>option:{record.name}</span>;
return <span>option:{record?.name}</span>;
};
render(
<AdminContext>
Expand Down Expand Up @@ -1054,7 +1055,7 @@ describe('<AutocompleteInput />', () => {
});

describe('onCreate', () => {
it('should include an option with the createLabel when the input is empty', async () => {
it("shouldn't include an option with the createLabel when the input is empty", async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
Expand Down Expand Up @@ -1093,7 +1094,53 @@ describe('<AutocompleteInput />', () => {
target: { value: '' },
});

expect(screen.queryByText('ra.action.create')).not.toBeNull();
expect(screen.queryByText('ra.action.create')).toBeNull();
expect(screen.queryByText('ra.action.create_item')).toBeNull();
});
it('should include an option with the custom createLabel when the input is empty', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
{ id: 'rea', name: 'React' },
];
const handleCreate = filter => {
const newChoice = {
id: 'js_fatigue',
name: filter,
};
choices.push(newChoice);
return newChoice;
};

render(
<AdminContext dataProvider={testDataProvider()}>
<SimpleForm
mode="onBlur"
onSubmit={jest.fn()}
defaultValues={{ language: 'ang' }}
>
<AutocompleteInput
source="language"
resource="posts"
choices={choices}
onCreate={handleCreate}
createLabel="Start typing to create a new item"
/>
</SimpleForm>
</AdminContext>
);

const input = screen.getByLabelText(
'resources.posts.fields.language'
) as HTMLInputElement;
input.focus();
fireEvent.change(input, {
target: { value: '' },
});

expect(
screen.queryByText('Start typing to create a new item')
).not.toBeNull();
expect(screen.queryByText('ra.action.create')).toBeNull();
expect(screen.queryByText('ra.action.create_item')).toBeNull();
});
it('should include an option with the createItemLabel when the input not empty', async () => {
Expand Down Expand Up @@ -1245,7 +1292,6 @@ describe('<AutocompleteInput />', () => {
fireEvent.focus(input);
expect(screen.queryByText('New Kid On The Block')).not.toBeNull();
});

it('should allow the creation of a new choice with a promise', async () => {
const choices = [
{ id: 'ang', name: 'Angular' },
Expand Down Expand Up @@ -1314,6 +1360,31 @@ describe('<AutocompleteInput />', () => {
fireEvent.focus(input);
expect(screen.queryByText('New Kid On The Block')).not.toBeNull();
});
it('should not use the createItemLabel as the value of the input', async () => {
render(<OnCreateSlow />);
await screen.findByText('Book War and Peace', undefined, {
timeout: 2000,
});
const input = screen.getByLabelText('Author') as HTMLInputElement;
await waitFor(
() => {
expect(input.value).toBe('Leo Tolstoy');
},
{ timeout: 2000 }
);
fireEvent.focus(input);
expect(screen.getAllByRole('option')).toHaveLength(4);
fireEvent.change(input, { target: { value: 'x' } });
await waitFor(
() => {
expect(screen.getAllByRole('option')).toHaveLength(1);
},
{ timeout: 2000 }
);
fireEvent.click(screen.getByText('Create x'));
expect(input.value).not.toBe('Create x');
expect(input.value).toBe('x');
}, 10000);
});
describe('create', () => {
it('should allow the creation of a new choice', async () => {
Expand Down
200 changes: 160 additions & 40 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { AutocompleteInput, AutocompleteInputProps } from './AutocompleteInput';
import { ReferenceInput } from './ReferenceInput';
import { TextInput } from './TextInput';
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';
import { useState } from 'react';

export default { title: 'ra-ui-materialui/input/AutocompleteInput' };

Expand Down Expand Up @@ -261,25 +262,34 @@ const choicesForCreationSupport = [
{ id: 5, name: 'Marcel Proust' },
];

export const OnCreate = () => (
<Wrapper>
const OnCreateInput = () => {
const [choices, setChoices] = useState(choicesForCreationSupport);
return (
<AutocompleteInput
source="author"
choices={choicesForCreationSupport}
onCreate={filter => {
choices={choices}
onCreate={async filter => {
if (!filter) return;

const newOption = {
id: choicesForCreationSupport.length + 1,
id: choices.length + 1,
name: filter,
};
choicesForCreationSupport.push(newOption);
setChoices(options => [...options, newOption]);
// Wait until next tick to give some time for React to update the state
await new Promise(resolve => setTimeout(resolve));
return newOption;
}}
TextFieldProps={{
placeholder: 'Start typing to create a new item',
}}
/>
);
};

export const OnCreate = () => (
<Wrapper>
<OnCreateInput />
</Wrapper>
);

Expand Down Expand Up @@ -326,61 +336,171 @@ export const OnCreateSlow = () => (
</Wrapper>
);

export const OnCreatePrompt = () => (
<Wrapper>
const OnCreatePromptInput = () => {
const [choices, setChoices] = useState(choicesForCreationSupport);
return (
<AutocompleteInput
source="author"
choices={choicesForCreationSupport}
onCreate={filter => {
choices={choices}
onCreate={async filter => {
const newAuthorName = window.prompt(
'Enter a new author',
filter
);

if (newAuthorName) {
const newAuthor = {
id: choicesForCreationSupport.length + 1,
name: newAuthorName,
};
choicesForCreationSupport.push(newAuthor);
return newAuthor;
}
if (!newAuthorName) return;
const newAuthor = {
id: choices.length + 1,
name: newAuthorName,
};
setChoices(authors => [...authors, newAuthor]);
slax57 marked this conversation as resolved.
Show resolved Hide resolved
// Wait until next tick to give some time for React to update the state
await new Promise(resolve => setTimeout(resolve));
return newAuthor;
}}
TextFieldProps={{
placeholder: 'Start typing to create a new item',
}}
// Disable clearOnBlur because opening the prompt blurs the input
// and creates a flicker
clearOnBlur={false}
/>
);
};

export const OnCreatePrompt = () => (
<Wrapper>
<OnCreatePromptInput />
</Wrapper>
);

export const CreateLabel = () => (
const CreateAuthorLocal = ({ choices, setChoices }) => {
const { filter, onCancel, onCreate } = useCreateSuggestionContext();
const [name, setName] = React.useState(filter || '');
const [language, setLanguage] = React.useState('');

const handleSubmit = event => {
event.preventDefault();
const newAuthor = {
id: choices.length + 1,
name,
language,
};
setChoices(authors => [...authors, newAuthor]);
setName('');
setLanguage('');
// Wait until next tick to give some time for React to update the state
setTimeout(() => {
onCreate(newAuthor);
});
};

return (
<Dialog open onClose={onCancel}>
<form onSubmit={handleSubmit}>
<DialogContent>
<Stack gap={4}>
<TextField
name="name"
label="The author name"
value={name}
onChange={event => setName(event.target.value)}
autoFocus
/>
<TextField
name="language"
label="The author language"
value={language}
onChange={event => setLanguage(event.target.value)}
autoFocus
/>
</Stack>
</DialogContent>
<DialogActions>
<Button type="submit">Save</Button>
<Button onClick={onCancel}>Cancel</Button>
</DialogActions>
</form>
</Dialog>
);
};

const CreateDialogInput = () => {
const [choices, setChoices] = useState(choicesForCreationSupport);
return (
<AutocompleteInput
source="author"
choices={choices}
create={
<CreateAuthorLocal choices={choices} setChoices={setChoices} />
}
TextFieldProps={{
placeholder: 'Start typing to create a new item',
}}
/>
);
};

export const CreateDialog = () => (
<Wrapper>
<CreateDialogInput />
</Wrapper>
);

const CreateLabelInput = () => {
const [choices, setChoices] = useState(choicesForCreationSupport);
return (
<AutocompleteInput
source="author"
choices={[
{ id: 1, name: 'Leo Tolstoy' },
{ id: 2, name: 'Victor Hugo' },
{ id: 3, name: 'William Shakespeare' },
{ id: 4, name: 'Charles Baudelaire' },
{ id: 5, name: 'Marcel Proust' },
]}
onCreate={filter => {
const newAuthorName = window.prompt(
'Enter a new author',
filter
);
choices={choices}
onCreate={async filter => {
if (!filter) return;

if (newAuthorName) {
const newAuthor = {
id: choicesForCreationSupport.length + 1,
name: newAuthorName,
};
choicesForCreationSupport.push(newAuthor);
return newAuthor;
}
const newOption = {
id: choices.length + 1,
name: filter,
};
setChoices(options => [...options, newOption]);
// Wait until next tick to give some time for React to update the state
await new Promise(resolve => setTimeout(resolve));
return newOption;
}}
createLabel="Start typing to create a new item"
/>
);
};

export const CreateLabel = () => (
<Wrapper>
<CreateLabelInput />
</Wrapper>
);
Comment on lines +472 to +476
Copy link
Contributor

@slax57 slax57 Oct 11, 2024

Choose a reason for hiding this comment

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

Found an issue in this story, where the Autocomplete displays the Create option for an item that already exists

2024-10-11_14-16

EDIT: This issue seems to exist prior to this PR => Will be fixed in another PR


const CreateItemLabelInput = () => {
const [choices, setChoices] = useState(choicesForCreationSupport);
return (
<AutocompleteInput
source="author"
choices={choices}
onCreate={async filter => {
if (!filter) return;

const newOption = {
id: choices.length + 1,
name: filter,
};
setChoices(options => [...options, newOption]);
// Wait until next tick to give some time for React to update the state
await new Promise(resolve => setTimeout(resolve));
return newOption;
}}
createItemLabel="Add a new author: %{item}"
/>
);
};

export const CreateItemLabel = () => (
<Wrapper>
<CreateItemLabelInput />
</Wrapper>
);

Expand Down
Loading