diff --git a/docs/SelectArrayInput.md b/docs/SelectArrayInput.md index e8451d116bf..9ef6f5163a3 100644 --- a/docs/SelectArrayInput.md +++ b/docs/SelectArrayInput.md @@ -409,6 +409,26 @@ In that case, set the `translateChoice` prop to `false`. ``` +## `emptyText` + +If the input isn't required (using `validate={required()}`), users can select an empty choice with an empty text `''` as label, when clicking on the empty option, all selected values ​​will be deselected. + +You can override that label with the `emptyText` prop. + + +```jsx + + + +``` + +The `emptyText` prop accepts either a string or a React Element. +And if you want to hide that empty choice, make the input required. + +```jsx + +``` + ## Fetching Choices If you want to populate the `choices` attribute with a list of related records, you should decorate `` with [``](./ReferenceArrayInput.md), and leave the `choices` empty: diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index c0e939dc9ae..8a62d2ca999 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -84,6 +84,18 @@ describe('', () => { expect(screen.queryByText('Photography')).not.toBeNull(); }); + it('should clear the options when clicking in emptyOption', () => { + render( + + + + + + + + ); + }); + it('should use optionValue as value identifier', () => { render( @@ -764,4 +776,67 @@ describe('', () => { }); }); }); + + it('should render the emptyText option correctly', () => { + const choices = [ + { id: 'programming', name: 'Programming' }, + { id: 'lifestyle', name: 'Lifestyle' }, + { id: 'photography', name: 'Photography' }, + ]; + + render( + + + + + + + + ); + + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.categories') + ); + expect(screen.getByText('Test Option')).toBeInTheDocument(); + }); + + it('should clear the options when clicking on emptyOption', async () => { + const choices = [ + { id: 'programming', name: 'Programming' }, + { id: 'lifestyle', name: 'Lifestyle' }, + { id: 'photography', name: 'Photography' }, + ]; + + render( + + + + + + + + ); + + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.categories') + ); + fireEvent.click(screen.getByText('Programming')); + fireEvent.click(screen.getByText('Lifestyle')); + expect( + screen.getByDisplayValue('programming,lifestyle') + ).toBeInTheDocument(); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.categories') + ); + fireEvent.click(screen.getByText('Clear selections')); + expect(screen.queryByDisplayValue('programming,lifestyle')).toBeNull(); + }); }); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx index 915f35b0564..81c208edd64 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx @@ -251,6 +251,22 @@ export const DefaultValue = () => ( ); +export const EmptyText = () => ( + + + +); + export const InsideArrayInput = () => ( { createLabel, createValue, disableValue = 'disabled', + emptyText = '', format, helperText, label, @@ -120,6 +123,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { ...rest } = props; + const translate = useTranslate(); + const inputLabel = useRef(null); const { @@ -172,21 +177,29 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { // We might receive an event from the mui component // In this case, it will be the choice id if (eventOrChoice?.target) { - // when used with different IDs types, unselection leads to double selection with both types - // instead of the value being removed from the array - // e.g. we receive eventOrChoice.target.value = [1, '2', 2] instead of [1] after removing 2 - // this snippet removes a value if it is present twice - eventOrChoice.target.value = eventOrChoice.target.value.reduce( - (acc, value) => { - // eslint-disable-next-line eqeqeq - const index = acc.findIndex(v => v == value); - return index < 0 - ? [...acc, value] - : [...acc.slice(0, index), ...acc.slice(index + 1)]; - }, - [] - ); - field.onChange(eventOrChoice); + // If the selectedValue is emptyValue, clears the selections + const selectedValue = eventOrChoice.target.value; + + if (selectedValue.includes('')) { + field.onChange([]); + } else { + // when used with different IDs types, unselection leads to double selection with both types + // instead of the value being removed from the array + // e.g. we receive eventOrChoice.target.value = [1, '2', 2] instead of [1] after removing 2 + // this snippet removes a value if it is present twice + eventOrChoice.target.value = + eventOrChoice.target.value.reduce((acc, value) => { + // eslint-disable-next-line eqeqeq + const index = acc.findIndex(v => v == value); + return index < 0 + ? [...acc, value] + : [ + ...acc.slice(0, index), + ...acc.slice(index + 1), + ]; + }, []); + field.onChange(eventOrChoice); + } } else { // Or we might receive a choice directly, for instance a newly created one field.onChange([ @@ -351,6 +364,22 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { value={finalValue} {...outlinedInputProps} > + {!isRequired && ( + + {typeof emptyText === 'string' + ? emptyText === '' + ? ' ' // em space, forces the display of an empty line of normal height + : translate(emptyText, { _: emptyText }) + : emptyText} + + )} {finalChoices.map(renderMenuItem)} {renderHelperText ? ( @@ -373,6 +402,7 @@ export type SelectArrayInputProps = ChoicesProps & Omit & { options?: SelectProps; disableValue?: string; + emptyText?: string | ReactElement; source?: string; onChange?: (event: ChangeEvent | RaRecord) => void; };