diff --git a/UPGRADE.md b/UPGRADE.md index 665a03236e2..72dde3e7011 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -784,4 +784,9 @@ const OrderEdit = (props) => ( ); -``` \ No newline at end of file +``` + +## helperText is handled the same way in all components + +Some components (such as ``) accepted the `helperText` in their `meta` prop. They now receive it directly in their props. +Besides, all components now display their error or their helperText, but not both at the same time. diff --git a/packages/ra-ui-materialui/src/input/InputWithOptions.ts b/packages/ra-ui-materialui/src/input/InputWithOptions.ts index 4483be59f71..a6a3469d3af 100644 --- a/packages/ra-ui-materialui/src/input/InputWithOptions.ts +++ b/packages/ra-ui-materialui/src/input/InputWithOptions.ts @@ -4,9 +4,9 @@ type OptionTextFunction = (record: ChoiceType) => string; export interface InputWithOptionsProps { choices: ChoiceType[]; - optionText: + optionText?: | string | OptionTextFunction | ReactElement<{ record: ChoiceType }>; - optionValue: string; + optionValue?: string; } diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.js b/packages/ra-ui-materialui/src/input/SelectArrayInput.js deleted file mode 100644 index 36937541906..00000000000 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.js +++ /dev/null @@ -1,296 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import get from 'lodash/get'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; -import InputLabel from '@material-ui/core/InputLabel'; -import Input from '@material-ui/core/Input'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import FormControl from '@material-ui/core/FormControl'; -import Chip from '@material-ui/core/Chip'; -import { withStyles, createStyles } from '@material-ui/core/styles'; -import compose from 'recompose/compose'; -import classnames from 'classnames'; -import { addField, translate, FieldTitle } from 'ra-core'; -import InputHelperText from './InputHelperText'; - -const sanitizeRestProps = ({ - addLabel, - allowEmpty, - basePath, - choices, - className, - component, - crudGetMatching, - crudGetOne, - defaultValue, - filter, - filterToQuery, - formClassName, - initializeForm, - input, - isRequired, - label, - limitChoicesToValue, - locale, - meta, - onChange, - options, - optionValue, - optionText, - perPage, - record, - reference, - resource, - setFilter, - setPagination, - setSort, - sort, - source, - textAlign, - translate, - translateChoice, - validation, - ...rest -}) => rest; - -const styles = theme => - createStyles({ - root: {}, - chips: { - display: 'flex', - flexWrap: 'wrap', - }, - chip: { - margin: theme.spacing(1 / 4), - }, - select: { - height: 'auto', - overflow: 'auto', - }, - }); - -/** - * An Input component for a select box allowing multiple selections, using an array of objects for the options - * - * Pass possible options as an array of objects in the 'choices' attribute. - * - * By default, the options are built from: - * - the 'id' property as the option value, - * - the 'name' property an the option text - * @example - * const choices = [ - * { id: 'programming', name: 'Programming' }, - * { id: 'lifestyle', name: 'Lifestyle' }, - * { id: 'photography', name: 'Photography' }, - * ]; - * - * - * You can also customize the properties to use for the option name and value, - * thanks to the 'optionText' and 'optionValue' attributes. - * @example - * const choices = [ - * { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, - * { _id: 456, full_name: 'Jane Austen', sex: 'F' }, - * ]; - * - * - * `optionText` also accepts a function, so you can shape the option text at will: - * @example - * const choices = [ - * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, - * { id: 456, first_name: 'Jane', last_name: 'Austen' }, - * ]; - * const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; - * - * - * `optionText` also accepts a React Element, that will be cloned and receive - * the related choice as the `record` prop. You can use Field components there. - * @example - * const choices = [ - * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, - * { id: 456, first_name: 'Jane', last_name: 'Austen' }, - * ]; - * const FullNameField = ({ record }) => {record.first_name} {record.last_name}; - * }/> - * - * The choices are translated by default, so you can use translation identifiers as choices: - * @example - * const choices = [ - * { id: 'programming', name: 'myroot.tags.programming' }, - * { id: 'lifestyle', name: 'myroot.tags.lifestyle' }, - * { id: 'photography', name: 'myroot.tags.photography' }, - * ]; - */ -export class SelectArrayInput extends Component { - /* - * Using state to bypass a redux-form comparison but which prevents re-rendering - * @see https://github.com/erikras/redux-form/issues/2456 - */ - state = { - value: this.props.input.value || [], - }; - - componentWillReceiveProps(nextProps) { - if (nextProps.input.value !== this.props.input.value) { - this.setState({ value: nextProps.input.value || [] }); - } - } - - handleChange = event => { - this.props.input.onChange(event.target.value); - // HACK: For some reason, redux-form does not consider this input touched without calling onBlur manually - this.props.input.onBlur(event.target.value); - this.setState({ value: event.target.value }); - }; - - renderMenuItemOption = choice => { - const { optionText, translate, translateChoice } = this.props; - if (React.isValidElement(optionText)) { - return React.cloneElement(optionText, { - record: choice, - }); - } - - const choiceName = - typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - return translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName; - }; - - renderMenuItem = choice => { - const { optionValue } = this.props; - return choice ? ( - - {this.renderMenuItemOption(choice)} - - ) : null; - }; - - render() { - const { - choices, - classes, - className, - isRequired, - label, - meta, - options, - resource, - source, - optionText, - optionValue, - helperText, - ...rest - } = this.props; - if (typeof meta === 'undefined') { - throw new Error( - "The SelectInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details." - ); - } - const { touched, error } = meta; - - return ( - - - - - - {helperText || (touched && error) ? ( - - - - ) : null} - - ); - } -} - -SelectArrayInput.propTypes = { - choices: PropTypes.arrayOf(PropTypes.object), - classes: PropTypes.object, - className: PropTypes.string, - children: PropTypes.node, - input: PropTypes.object, - isRequired: PropTypes.bool, - label: PropTypes.string, - meta: PropTypes.object, - options: PropTypes.object, - optionText: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.func, - PropTypes.element, - ]).isRequired, - optionValue: PropTypes.string.isRequired, - resource: PropTypes.string, - source: PropTypes.string, - translate: PropTypes.func.isRequired, - translateChoice: PropTypes.bool, -}; - -SelectArrayInput.defaultProps = { - classes: {}, - choices: [], - options: {}, - optionText: 'name', - optionValue: 'id', - translateChoice: true, -}; - -const EnhancedSelectArrayInput = compose( - addField, - translate, - withStyles(styles) -)(SelectArrayInput); - -export default EnhancedSelectArrayInput; diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js deleted file mode 100644 index 4d20fa1b0bc..00000000000 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import React from 'react'; -import expect from 'expect'; -import { render, cleanup } from '@testing-library/react'; - -import { SelectArrayInput } from './SelectArrayInput'; - -describe('', () => { - const defaultProps = { - classes: {}, - resource: 'bar', - source: 'foo', - meta: {}, - input: { onChange: () => null, onBlur: () => null }, - translate: x => x, - }; - - afterEach(cleanup); - - it('should use a mui Select', () => { - const { queryByTestId } = render( - - ); - expect(queryByTestId('selectArray')).toBeDefined(); - }); - - it('should use the input parameter value as the initial input value', () => { - const { getByLabelText } = render( - - ); - expect(getByLabelText('resources.bar.fields.foo').value).toBe( - 'programming,lifestyle' - ); - }); - - it('should reveal choices on click', () => { - const { getByRole, queryByText } = render( - - ); - expect(queryByText('Programming')).toBeNull(); - expect(queryByText('Lifestyle')).toBeNull(); - expect(queryByText('Photography')).toBeNull(); - getByRole('button').click(); - expect(queryByText('Programming')).not.toBeNull(); - expect(queryByText('Lifestyle')).not.toBeNull(); - expect(queryByText('Photography')).not.toBeNull(); - }); - - it('should use optionValue as value identifier', () => { - const { getByRole, getByText, getByLabelText } = render( - - ); - getByRole('button').click(); - getByText('Male').click(); - expect(getByLabelText('resources.bar.fields.foo').value).toBe('M'); - }); - - it('should use optionValue including "." as value identifier', () => { - const { getByRole, getByText, getByLabelText } = render( - - ); - getByRole('button').click(); - getByText('Male').click(); - expect(getByLabelText('resources.bar.fields.foo').value).toBe('M'); - }); - - it('should use optionText with a string value as text identifier', () => { - const { getByRole, queryByText } = render( - - ); - getByRole('button').click(); - expect(queryByText('Male')).not.toBeNull(); - }); - - it('should use optionText with a string value including "." as text identifier', () => { - const { getByRole, queryByText } = render( - - ); - getByRole('button').click(); - expect(queryByText('Male')).not.toBeNull(); - }); - - it('should use optionText with a function value as text identifier', () => { - const { getByRole, queryByText } = render( - choice.foobar} - choices={[{ id: 'M', foobar: 'Male' }]} - /> - ); - getByRole('button').click(); - expect(queryByText('Male')).not.toBeNull(); - }); - - it('should use optionText with an element value as text identifier', () => { - const Foobar = ({ record }) => {record.foobar}; - const { getByRole, queryByText } = render( - } - choices={[{ id: 'M', foobar: 'Male' }]} - /> - ); - getByRole('button').click(); - expect(queryByText('Male')).not.toBeNull(); - }); - - it('should translate the choices', () => { - const { getByRole, queryByText } = render( - `**${x}**`} - /> - ); - getByRole('button').click(); - expect(queryByText('**Male**')).not.toBeNull(); - expect(queryByText('**Female**')).not.toBeNull(); - }); - - it('should displayed helperText if prop is present in meta', () => { - const { queryByText } = render( - - ); - expect(queryByText('Can I help you?')).toBeDefined(); - }); - - describe('error message', () => { - it('should not be displayed if field is pristine', () => { - const { queryByText } = render( - - ); - expect(queryByText('Required field.')).toBeNull(); - }); - - it('should be displayed if field has been touched and is invalid', () => { - const { queryByText } = render( - - ); - expect(queryByText('Required field.')).toBeDefined(); - }); - - it('should be displayed even with an helper Text', () => { - const { queryByText } = render( - - ); - expect(queryByText('Required field.')).toBeDefined(); - expect(queryByText('Can I help you?')).toBeNull(); - }); - }); -}); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx new file mode 100644 index 00000000000..a5656d1ba41 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import expect from 'expect'; +import { render, cleanup } from '@testing-library/react'; +import { Form } from 'react-final-form'; +import { TranslationContext } from 'ra-core'; + +import SelectArrayInput from './SelectArrayInput'; + +describe('', () => { + const defaultProps = { + resource: 'posts', + source: 'categories', + choices: [ + { id: 'programming', name: 'Programming' }, + { id: 'lifestyle', name: 'Lifestyle' }, + { id: 'photography', name: 'Photography' }, + ], + }; + + afterEach(cleanup); + + it('should use a mui Select', () => { + const { queryByTestId } = render( +
} + /> + ); + expect(queryByTestId('selectArray')).toBeDefined(); + }); + + it('should use the input parameter value as the initial input value', () => { + const { getByLabelText } = render( + } + /> + ); + const input = getByLabelText( + 'resources.posts.fields.categories' + ) as HTMLInputElement; + expect(input.value).toBe('programming,lifestyle'); + }); + + it('should reveal choices on click', () => { + const { getByRole, queryByText } = render( + } + /> + ); + expect(queryByText('Programming')).toBeNull(); + expect(queryByText('Lifestyle')).toBeNull(); + expect(queryByText('Photography')).toBeNull(); + getByRole('button').click(); + expect(queryByText('Programming')).not.toBeNull(); + expect(queryByText('Lifestyle')).not.toBeNull(); + expect(queryByText('Photography')).not.toBeNull(); + }); + + it('should use optionValue as value identifier', () => { + const { getByRole, getByText, getByLabelText } = render( + ( + + )} + /> + ); + getByRole('button').click(); + getByText('Programming').click(); + expect(getByLabelText('resources.posts.fields.categories').value).toBe( + 'programming' + ); + }); + + it('should use optionValue including "." as value identifier', () => { + const { getByRole, getByText, getByLabelText } = render( + ( + + )} + /> + ); + getByRole('button').click(); + getByText('Programming').click(); + expect(getByLabelText('resources.posts.fields.categories').value).toBe( + 'programming' + ); + }); + + it('should use optionText with a string value as text identifier', () => { + const { getByRole, queryByText } = render( + ( + + )} + /> + ); + getByRole('button').click(); + expect(queryByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with a string value including "." as text identifier', () => { + const { getByRole, queryByText } = render( + ( + + )} + /> + ); + getByRole('button').click(); + expect(queryByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with a function value as text identifier', () => { + const { getByRole, queryByText } = render( + ( + choice.foobar} + choices={[{ id: 'programming', foobar: 'Programming' }]} + /> + )} + /> + ); + getByRole('button').click(); + expect(queryByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with an element value as text identifier', () => { + const Foobar = ({ record = undefined }) => {record.foobar}; + const { getByRole, queryByText } = render( + ( + } + choices={[{ id: 'programming', foobar: 'Programming' }]} + /> + )} + /> + ); + getByRole('button').click(); + expect(queryByText('Programming')).not.toBeNull(); + }); + + it('should translate the choices', () => { + const { getByRole, queryByText } = render( + `**${x}**`, + }} + > + } + /> + + ); + getByRole('button').click(); + expect(queryByText('**Programming**')).not.toBeNull(); + expect(queryByText('**Lifestyle**')).not.toBeNull(); + }); + + it('should display helperText if prop is specified', () => { + const { queryByText } = render( + ( + + )} + /> + ); + expect(queryByText('Can I help you?')).toBeDefined(); + }); + + describe('error message', () => { + it('should not be displayed if field is pristine', () => { + const validate = () => 'Required field.'; + const { queryByText } = render( + ( + + )} + /> + ); + expect(queryByText('ra.validation.required')).toBeNull(); + }); + + it('should be displayed if field has been touched and is invalid', () => { + const validate = () => 'Required field.'; + const { queryByText } = render( + ( + + )} + /> + ); + expect(queryByText('Required field.')).toBeDefined(); + }); + }); +}); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx new file mode 100644 index 00000000000..7b25e1590bb --- /dev/null +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -0,0 +1,284 @@ +import React, { FunctionComponent, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import get from 'lodash/get'; +import { + makeStyles, + Select, + MenuItem, + InputLabel, + Input, + FormHelperText, + FormControl, + Chip, +} from '@material-ui/core'; +import classnames from 'classnames'; +import { FieldTitle, useInput, useTranslate, InputProps } from 'ra-core'; +import InputHelperText from './InputHelperText'; +import { InputWithOptionsProps } from './InputWithOptions'; +import { SelectProps } from '@material-ui/core/Select'; +import { FormControlProps } from '@material-ui/core/FormControl'; + +const sanitizeRestProps = ({ + addLabel, + allowEmpty, + basePath, + choices, + className, + component, + crudGetMatching, + crudGetOne, + defaultValue, + filter, + filterToQuery, + formClassName, + initializeForm, + input, + isRequired, + label, + limitChoicesToValue, + locale, + meta, + onChange, + options, + optionValue, + optionText, + perPage, + record, + reference, + resource, + setFilter, + setPagination, + setSort, + sort, + source, + textAlign, + translate, + translateChoice, + validation, + ...rest +}: any) => rest; + +const useStyles = makeStyles(theme => ({ + root: {}, + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: theme.spacing(1 / 4), + }, + select: { + height: 'auto', + overflow: 'auto', + }, +})); + +/** + * An Input component for a select box allowing multiple selections, using an array of objects for the options + * + * Pass possible options as an array of objects in the 'choices' attribute. + * + * By default, the options are built from: + * - the 'id' property as the option value, + * - the 'name' property an the option text + * @example + * const choices = [ + * { id: 'programming', name: 'Programming' }, + * { id: 'lifestyle', name: 'Lifestyle' }, + * { id: 'photography', name: 'Photography' }, + * ]; + * + * + * You can also customize the properties to use for the option name and value, + * thanks to the 'optionText' and 'optionValue' attributes. + * @example + * const choices = [ + * { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, + * { _id: 456, full_name: 'Jane Austen', sex: 'F' }, + * ]; + * + * + * `optionText` also accepts a function, so you can shape the option text at will: + * @example + * const choices = [ + * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + * { id: 456, first_name: 'Jane', last_name: 'Austen' }, + * ]; + * const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; + * + * + * `optionText` also accepts a React Element, that will be cloned and receive + * the related choice as the `record` prop. You can use Field components there. + * @example + * const choices = [ + * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + * { id: 456, first_name: 'Jane', last_name: 'Austen' }, + * ]; + * const FullNameField = ({ record }) => {record.first_name} {record.last_name}; + * }/> + * + * The choices are translated by default, so you can use translation identifiers as choices: + * @example + * const choices = [ + * { id: 'programming', name: 'myroot.tags.programming' }, + * { id: 'lifestyle', name: 'myroot.tags.lifestyle' }, + * { id: 'photography', name: 'myroot.tags.photography' }, + * ]; + */ +const SelectArrayInput: FunctionComponent< + InputWithOptionsProps & InputProps & FormControlProps +> = ({ + choices, + classes: classesOverride, + className, + label, + helperText, + onBlur, + onChange, + onFocus, + options, + optionText, + optionValue, + resource, + source, + translateChoice, + validate, + ...rest +}) => { + const classes = useStyles({ classes: classesOverride }); + + const translate = useTranslate(); + + const { + id, + input, + isRequired, + meta: { error, touched }, + } = useInput({ + onBlur, + onChange, + onFocus, + resource, + source, + validate, + ...rest, + }); + + const renderMenuItemOption = useCallback( + choice => { + if (React.isValidElement<{ record: any }>(optionText)) { + return React.cloneElement(optionText, { + record: choice, + }); + } + + const choiceName = + typeof optionText === 'function' + ? optionText(choice) + : get(choice, optionText); + + return translateChoice + ? translate(choiceName, { _: choiceName }) + : choiceName; + }, + [optionText, translate, translateChoice] + ); + + const renderMenuItem = useCallback( + choice => { + return choice ? ( + + {renderMenuItemOption(choice)} + + ) : null; + }, + [optionValue, renderMenuItemOption] + ); + + return ( + + + + + + {helperText || (touched && error) ? ( + + + + ) : null} + + ); +}; + +SelectArrayInput.propTypes = { + choices: PropTypes.arrayOf(PropTypes.object), + classes: PropTypes.object, + className: PropTypes.string, + children: PropTypes.node, + label: PropTypes.string, + options: PropTypes.object, + optionText: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + PropTypes.element, + ]).isRequired, + optionValue: PropTypes.string.isRequired, + resource: PropTypes.string, + source: PropTypes.string, + translateChoice: PropTypes.bool, +}; + +SelectArrayInput.defaultProps = { + choices: [], + options: {}, + optionText: 'name', + optionValue: 'id', + translateChoice: true, +}; + +export default SelectArrayInput;