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 6 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
8 changes: 3 additions & 5 deletions docs/AutocompleteInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,12 @@ 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.
To renders a "Create" menu item at the bottom of the list, you can use the `createLabel` prop.
This new item will only render when the input is empty to suggest user to write a new storable option.
slax57 marked this conversation as resolved.
Show resolved Hide resolved

![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
122 changes: 82 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]);
// Waiting for the nex tick to wait the useState to be updated
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,93 @@ 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
return newAuthor;
}}
TextFieldProps={{
placeholder: 'Start typing to create a new item',
}}
/>
);
};

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

const CreateLabelInput = () => {
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]);
// Waiting for the nex tick to wait the useState to be updated
await new Promise(resolve => setTimeout(resolve));
return newOption;
}}
createLabel="Start typing to create a new item"
/>
);
};

export const CreateLabel = () => (
<Wrapper>
<CreateLabelInput />
</Wrapper>
);

const CreateItemLabelInput = () => {
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]);
// Waiting for the nex tick to wait the useState to be updated
await new Promise(resolve => setTimeout(resolve));
return newOption;
}}
createLabel="Start typing to create a new item"
createItemLabel="Add a new author: %{item}"
/>
);
};

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

Expand Down
12 changes: 9 additions & 3 deletions packages/ra-ui-materialui/src/input/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const AutocompleteInput = <
closeText = 'ra.action.close',
create,
createLabel,
createItemLabel,
createItemLabel = 'ra.action.create_item',
createValue,
debounce: debounceDelay = 250,
defaultValue,
Expand Down Expand Up @@ -465,7 +465,13 @@ If you provided a React element for the optionText prop, you must also provide t
event?.type === 'change' ||
!doesQueryMatchSelection(newInputValue)
) {
setFilterValue(newInputValue);
const createOptionLabel = translate(createItemLabel, {
item: filterValue,
_: createItemLabel,
});
const isCreate = newInputValue === createOptionLabel;
const valueToSet = isCreate ? filterValue : newInputValue;
setFilterValue(valueToSet);
debouncedSetFilter(newInputValue);
}
if (reason === 'clear') {
Expand Down Expand Up @@ -513,7 +519,7 @@ If you provided a React element for the optionText prop, you must also provide t
// add create option if necessary
const { inputValue } = params;
if (onCreate || create) {
if (inputValue === '') {
if (inputValue === '' && createLabel) {
// create option with createLabel
filteredOptions = filteredOptions.concat(getCreateItem(''));
} else if (!doesQueryMatchSuggestion(filterValue)) {
Expand Down
Loading