Skip to content

Added emptyText to SelectArrayInput component #10504

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

Open
wants to merge 6 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions docs/SelectArrayInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,26 @@ In that case, set the `translateChoice` prop to `false`.
<SelectArrayInput source="roles" choices={choices} translateChoice={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

<SelectInput source="category" choices={choices} emptyText="No category selected" />

```

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
<SelectInput source="category" choices={choices} validate={required()} />
```

## Fetching Choices

If you want to populate the `choices` attribute with a list of related records, you should decorate `<SelectArrayInput>` with [`<ReferenceArrayInput>`](./ReferenceArrayInput.md), and leave the `choices` empty:
Expand Down
75 changes: 75 additions & 0 deletions packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ describe('<SelectArrayInput />', () => {
expect(screen.queryByText('Photography')).not.toBeNull();
});

it('should clear the options when clicking in emptyOption', () => {
render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="posts">
<SimpleForm onSubmit={jest.fn()}>
<SelectArrayInput {...defaultProps} />
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);
});

it('should use optionValue as value identifier', () => {
render(
<AdminContext dataProvider={testDataProvider()}>
Expand Down Expand Up @@ -764,4 +776,67 @@ describe('<SelectArrayInput />', () => {
});
});
});

it('should render the emptyValue option correctly', () => {
const choices = [
{ id: 'programming', name: 'Programming' },
{ id: 'lifestyle', name: 'Lifestyle' },
{ id: 'photography', name: 'Photography' },
];

render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="posts">
<SimpleForm onSubmit={jest.fn()}>
<SelectArrayInput
{...defaultProps}
choices={choices}
emptyText="Test Option"
/>
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);

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(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="posts">
<SimpleForm onSubmit={jest.fn()}>
<SelectArrayInput
{...defaultProps}
choices={choices}
emptyText="Clear selections"
/>
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);

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();
});
});
16 changes: 16 additions & 0 deletions packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,22 @@ export const DefaultValue = () => (
</Wrapper>
);

export const EmptyText = () => (
<Wrapper>
<SelectArrayInput
source="roles"
choices={[
{ id: 'admin', name: 'Admin' },
{ id: 'u001', name: 'Editor' },
{ id: 'u002', name: 'Moderator' },
{ id: 'u003', name: 'Reviewer' },
]}
emptyText={'Clear'}
sx={{ width: 300 }}
/>
</Wrapper>
);

export const InsideArrayInput = () => (
<Wrapper>
<ArrayInput
Expand Down
64 changes: 49 additions & 15 deletions packages/ra-ui-materialui/src/input/SelectArrayInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { ReactElement } from 'react';
import { styled } from '@mui/material/styles';
import { useCallback, useRef, ChangeEvent } from 'react';
import clsx from 'clsx';
Expand All @@ -20,6 +21,7 @@ import {
useChoices,
RaRecord,
useGetRecordRepresentation,
useTranslate,
} from 'ra-core';
import { InputHelperText } from './InputHelperText';
import { FormControlProps } from '@mui/material/FormControl';
Expand Down Expand Up @@ -95,6 +97,7 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => {
createLabel,
createValue,
disableValue = 'disabled',
emptyText = '',
format,
helperText,
label,
Expand All @@ -120,6 +123,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => {
...rest
} = props;

const translate = useTranslate();

const inputLabel = useRef(null);

const {
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -217,6 +230,14 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => {
? [...(allChoices || []), createItem]
: allChoices || [];

const renderEmptyItemOption = useCallback(() => {
return typeof emptyText === 'string'
? emptyText === ''
? ' ' // em space, forces the display of an empty line of normal height
: translate(emptyText, { _: emptyText })
: emptyText;
}, [emptyText, translate]);

const renderMenuItemOption = useCallback(
choice =>
!!createItem &&
Expand Down Expand Up @@ -351,6 +372,18 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => {
value={finalValue}
{...outlinedInputProps}
>
{!isRequired && (
<MenuItem
value={''}
key="null"
aria-label={translate(
'ra.action.clear_input_value'
)}
title={translate('ra.action.clear_input_value')}
>
{renderEmptyItemOption()}
</MenuItem>
)}
{finalChoices.map(renderMenuItem)}
</Select>
{renderHelperText ? (
Expand All @@ -373,6 +406,7 @@ export type SelectArrayInputProps = ChoicesProps &
Omit<FormControlProps, 'defaultValue' | 'onBlur' | 'onChange'> & {
options?: SelectProps;
disableValue?: string;
emptyText?: string | ReactElement;
source?: string;
onChange?: (event: ChangeEvent<HTMLInputElement> | RaRecord) => void;
};
Expand Down