diff --git a/docs/AutocompleteInput.md b/docs/AutocompleteInput.md index f21d5cf6a8a..50bbcb28c81 100644 --- a/docs/AutocompleteInput.md +++ b/docs/AutocompleteInput.md @@ -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, `` 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, `` 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 ``, 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 ', () => { ]; const OptionText = () => { const record = useRecordContext(); - return option:{record.name}; + return option:{record?.name}; }; render( @@ -1054,7 +1055,7 @@ describe('', () => { }); 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' }, @@ -1093,7 +1094,53 @@ describe('', () => { 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( + + + + + + ); + + 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 () => { @@ -1245,7 +1292,6 @@ describe('', () => { 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' }, @@ -1314,6 +1360,31 @@ describe('', () => { 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(); + 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 () => { diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index 4d0a33bf43c..c8f99f5999a 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -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' }; @@ -261,25 +262,34 @@ const choicesForCreationSupport = [ { id: 5, name: 'Marcel Proust' }, ]; -export const OnCreate = () => ( - +const OnCreateInput = () => { + const [choices, setChoices] = useState(choicesForCreationSupport); + return ( { + 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 = () => ( + + ); @@ -326,61 +336,171 @@ export const OnCreateSlow = () => ( ); -export const OnCreatePrompt = () => ( - +const OnCreatePromptInput = () => { + const [choices, setChoices] = useState(choicesForCreationSupport); + return ( { + 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]); + // 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 = () => ( + + ); -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 ( + +
+ + + setName(event.target.value)} + autoFocus + /> + setLanguage(event.target.value)} + autoFocus + /> + + + + + + +
+
+ ); +}; + +const CreateDialogInput = () => { + const [choices, setChoices] = useState(choicesForCreationSupport); + return ( + + } + TextFieldProps={{ + placeholder: 'Start typing to create a new item', + }} + /> + ); +}; + +export const CreateDialog = () => ( + + +); + +const CreateLabelInput = () => { + const [choices, setChoices] = useState(choicesForCreationSupport); + return ( { - 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 = () => ( + + + +); + +const CreateItemLabelInput = () => { + const [choices, setChoices] = useState(choicesForCreationSupport); + return ( + { + 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 = () => ( + + ); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 008a4d52daf..576aaa7d491 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -133,7 +133,7 @@ export const AutocompleteInput = < closeText = 'ra.action.close', create, createLabel, - createItemLabel, + createItemLabel = 'ra.action.create_item', createValue, debounce: debounceDelay = 250, defaultValue, @@ -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') { @@ -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)) {