From 07c126c9c4f99dbd7471a3740213928f5cd34799 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 9 Aug 2019 18:27:40 +0200 Subject: [PATCH] [RFR] Convert CheckboxGroupInput to use useInput --- .../src/input/CheckboxGroupInput.js | 242 +++++++----------- .../src/input/CheckboxGroupInput.spec.js | 240 ++++++++++------- .../src/input/CheckboxGroupInputItem.js | 62 +++++ 3 files changed, 307 insertions(+), 237 deletions(-) create mode 100644 packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.js diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.js b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.js index 277c5ed7774..f0d2d19fedb 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.js +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.js @@ -1,33 +1,25 @@ -import React, { Component } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; -import get from 'lodash/get'; import FormLabel from '@material-ui/core/FormLabel'; import FormControl from '@material-ui/core/FormControl'; import FormGroup from '@material-ui/core/FormGroup'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormHelperText from '@material-ui/core/FormHelperText'; -import Checkbox from '@material-ui/core/Checkbox'; -import { withStyles, createStyles } from '@material-ui/core/styles'; -import compose from 'recompose/compose'; -import { addField, translate, FieldTitle } from 'ra-core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FieldTitle, useInput } from 'ra-core'; import defaultSanitizeRestProps from './sanitizeRestProps'; +import CheckboxGroupInputItem from './CheckboxGroupInputItem'; + const sanitizeRestProps = ({ setFilter, setPagination, setSort, ...rest }) => defaultSanitizeRestProps(rest); -const styles = theme => - createStyles({ - root: {}, - label: { - transform: 'translate(0, 1.5px) scale(0.75)', - transformOrigin: `top ${ - theme.direction === 'ltr' ? 'left' : 'right' - }`, - }, - checkbox: { - height: 32, - }, - }); +const useStyles = makeStyles(theme => ({ + root: {}, + label: { + transform: 'translate(0, 1.5px) scale(0.75)', + transformOrigin: `top ${theme.direction === 'ltr' ? 'left' : 'right'}`, + }, +})); /** * An Input component for a checkbox group, using an array of objects for the options @@ -91,131 +83,99 @@ const styles = theme => * * The object passed as `options` props is passed to the material-ui components */ -export class CheckboxGroupInput extends Component { - handleCheck = (event, isChecked) => { - const { - input: { value, onChange }, - } = this.props; - let newValue; - try { - // try to convert string value to number, e.g. '123' - newValue = JSON.parse(event.target.value); - } catch (e) { - // impossible to convert value, e.g. 'abc' - newValue = event.target.value; - } - if (isChecked) { - onChange([...(value || []), ...[newValue]]); - } else { - onChange(value.filter(v => v != newValue)); // eslint-disable-line eqeqeq - } - }; - - renderCheckbox = choice => { - const { - id, - input: { value }, - optionText, - optionValue, - options, - translate, - translateChoice, - classes, - } = this.props; - - const choiceName = React.isValidElement(optionText) - ? React.cloneElement(optionText, { record: choice }) - : typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - return ( - v == get(choice, optionValue)) !== // eslint-disable-line eqeqeq - undefined - : false - } - onChange={this.handleCheck} - value={String(get(choice, optionValue))} - control={ - - } - label={ - translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName - } - /> - ); - }; +const CheckboxGroupInput = ({ + choices, + helperText, + label, + onBlur, + onChange, + onFocus, + optionText, + optionValue, + options, + resource, + source, + translate, + translateChoice, + validate, + ...rest +}) => { + const classes = useStyles(); - render() { - const { - choices, - className, - classes = {}, - isRequired, - label, - meta, - resource, - source, - input, - ...rest - } = this.props; - if (typeof meta === 'undefined') { - throw new Error( - "The CheckboxGroupInput component wasn't called within a react-final-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 { + id, + input: { onChange: finalFormOnChange, onBlur: finalFormOnBlur, value }, + isRequired, + meta: { error, touched }, + } = useInput({ + onBlur, + onChange, + onFocus, + resource, + source, + validate, + }); - const { touched, error, helperText = false } = meta; + const handleCheck = useCallback( + (event, isChecked) => { + let newValue; + try { + // try to convert string value to number, e.g. '123' + newValue = JSON.parse(event.target.value); + } catch (e) { + // impossible to convert value, e.g. 'abc' + newValue = event.target.value; + } + if (isChecked) { + finalFormOnChange([...(value || []), ...[newValue]]); + } else { + finalFormOnChange(value.filter(v => v != newValue)); // eslint-disable-line eqeqeq + } + finalFormOnBlur(); // HACK: See https://github.com/final-form/react-final-form/issues/365#issuecomment-515045503 + }, + [finalFormOnChange, finalFormOnBlur, value] + ); - return ( - - - + + + + + {choices.map(choice => ( + - - {choices.map(this.renderCheckbox)} - {touched && error && ( - {error} - )} - {helperText && {helperText}} - - ); - } -} + ))} + + {touched && error && {error}} + {helperText && {helperText}} + + ); +}; CheckboxGroupInput.propTypes = { choices: PropTypes.arrayOf(PropTypes.object), - classes: PropTypes.object, className: PropTypes.string, label: PropTypes.string, source: PropTypes.string, options: PropTypes.object, - id: PropTypes.string, - input: PropTypes.shape({ - onChange: PropTypes.func.isRequired, - }), - isRequired: PropTypes.bool, optionText: PropTypes.oneOfType([ PropTypes.string, PropTypes.func, @@ -223,28 +183,16 @@ CheckboxGroupInput.propTypes = { ]).isRequired, optionValue: PropTypes.string.isRequired, resource: PropTypes.string, - translate: PropTypes.func.isRequired, translateChoice: PropTypes.bool.isRequired, - meta: PropTypes.object, }; CheckboxGroupInput.defaultProps = { choices: [], - classes: {}, options: {}, optionText: 'name', optionValue: 'id', translateChoice: true, -}; - -const EnhancedCheckboxGroupInput = compose( - addField, - translate, - withStyles(styles) -)(CheckboxGroupInput); - -EnhancedCheckboxGroupInput.defaultProps = { fullWidth: true, }; -export default EnhancedCheckboxGroupInput; +export default CheckboxGroupInput; diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.js b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.js index bd1dc4dae9e..12d2a9dd6c2 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.js +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.js @@ -1,30 +1,24 @@ import React from 'react'; import expect from 'expect'; -import { CheckboxGroupInput } from './CheckboxGroupInput'; -import { render, cleanup } from '@testing-library/react'; +import CheckboxGroupInput from './CheckboxGroupInput'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { Form } from 'react-final-form'; +import { renderWithRedux } from 'ra-core'; describe('', () => { const defaultProps = { - source: 'foo', - meta: {}, - choices: [{ id: 1, name: 'John doe' }], - input: { - onChange: () => {}, - value: [], - }, - translate: x => x, + source: 'tags', + resource: 'posts', + choices: [{ id: 'ang', name: 'Angular' }, { id: 'rct', name: 'React' }], }; afterEach(cleanup); it('should render choices as checkbox components', () => { const { getByLabelText } = render( - } /> ); const input1 = getByLabelText('Angular'); @@ -39,27 +33,39 @@ describe('', () => { it('should use the input parameter value as the initial input value', () => { const { getByLabelText } = render( - {} }} +
( + + )} /> ); const input1 = getByLabelText('Angular'); - expect(input1.checked).toBe(true); + expect(input1.checked).toEqual(true); const input2 = getByLabelText('React'); - expect(input2.checked).toBe(false); + expect(input2.checked).toEqual(false); }); it('should use optionValue as value identifier', () => { const { getByLabelText } = render( - ( + + )} /> ); expect(getByLabelText('Bar').value).toBe('foo'); @@ -67,10 +73,15 @@ describe('', () => { it('should use optionValue including "." as value identifier', () => { const { getByLabelText } = render( - ( + + )} /> ); expect(getByLabelText('Bar').value).toBe('foo'); @@ -78,10 +89,15 @@ describe('', () => { it('should use optionText with a string value as text identifier', () => { const { queryByLabelText } = render( - ( + + )} /> ); expect(queryByLabelText('Bar')).not.toBeNull(); @@ -89,10 +105,15 @@ describe('', () => { it('should use optionText with a string value including "." as text identifier', () => { const { queryByLabelText } = render( - ( + + )} /> ); expect(queryByLabelText('Bar')).not.toBeNull(); @@ -100,10 +121,15 @@ describe('', () => { it('should use optionText with a function value as text identifier', () => { const { queryByLabelText } = render( - choice.foobar} - choices={[{ id: 'foo', foobar: 'Bar' }]} + ( + choice.foobar} + choices={[{ id: 'foo', foobar: 'Bar' }]} + /> + )} /> ); expect(queryByLabelText('Bar')).not.toBeNull(); @@ -114,10 +140,15 @@ describe('', () => { {record.foobar} ); const { queryByLabelText, queryByTestId } = render( - } - choices={[{ id: 'foo', foobar: 'Bar' }]} + ( + } + choices={[{ id: 'foo', foobar: 'Bar' }]} + /> + )} /> ); expect(queryByLabelText('Bar')).not.toBeNull(); @@ -125,29 +156,60 @@ describe('', () => { }); it('should translate the choices by default', () => { - const { queryByLabelText } = render( - `**${x}**`} /> + const { queryByLabelText } = renderWithRedux( + } + />, + { + i18n: { + messages: { + Angular: 'Angular **', + React: 'React **', + }, + }, + } ); - expect(queryByLabelText('**John doe**')).not.toBeNull(); + expect(queryByLabelText('Angular **')).not.toBeNull(); + expect(queryByLabelText('React **')).not.toBeNull(); }); it('should not translate the choices if translateChoice is false', () => { - const { queryByLabelText } = render( - `**${x}**`} - translateChoice={false} - /> + const { queryByLabelText } = renderWithRedux( + ( + + )} + />, + { + i18n: { + messages: { + Angular: 'Angular **', + React: 'React **', + }, + }, + } ); - expect(queryByLabelText('**John doe**')).toBeNull(); - expect(queryByLabelText('John doe')).not.toBeNull(); + expect(queryByLabelText('Angular **')).toBeNull(); + expect(queryByLabelText('React **')).toBeNull(); + expect(queryByLabelText('Angular')).not.toBeNull(); + expect(queryByLabelText('React')).not.toBeNull(); }); - it('should displayed helperText if prop is present in meta', () => { + it('should display helperText', () => { const { queryByText } = render( - ( + + )} /> ); expect(queryByText('Can I help you?')).not.toBeNull(); @@ -156,9 +218,9 @@ describe('', () => { describe('error message', () => { it('should not be displayed if field is pristine', () => { const { container } = render( - } /> ); expect(container.querySelector('p')).toBeNull(); @@ -166,38 +228,36 @@ describe('', () => { it('should not be displayed if field has been touched but is valid', () => { const { container } = render( - } /> ); expect(container.querySelector('p')).toBeNull(); }); it('should be displayed if field has been touched and is invalid', () => { - const { container, queryByText } = render( - - ); - expect(container.querySelector('p')).not.toBeNull(); - expect(queryByText('Required field.')).not.toBeNull(); - }); + // This validator always returns an error + const validate = () => 'ra.validation.error'; - it('should display the error and help text if helperText is present', () => { - const { queryByText } = render( - ( + + )} /> ); - expect(queryByText('Required field.')).not.toBeNull(); - expect(queryByText('Can I help you?')).not.toBeNull(); + const input = queryByLabelText('Angular'); + fireEvent.click(input); + expect(input.checked).toBe(true); + + fireEvent.blur(input); + expect(queryByText('ra.validation.error')).not.toBeNull(); }); }); }); diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.js b/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.js new file mode 100644 index 00000000000..1f11353ad09 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.js @@ -0,0 +1,62 @@ +import React from 'react'; +import get from 'lodash/get'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import { makeStyles } from '@material-ui/core/styles'; +import { useTranslate } from 'ra-core'; + +const useStyles = makeStyles({ + checkbox: { + height: 32, + }, +}); + +const CheckboxGroupInputItem = ({ + id, + choice, + onChange, + options, + optionText, + optionValue, + translateChoice, + value, +}) => { + const classes = useStyles(); + const translate = useTranslate(); + + const choiceName = React.isValidElement(optionText) + ? React.cloneElement(optionText, { record: choice }) + : typeof optionText === 'function' + ? optionText(choice) + : get(choice, optionText); + + return ( + v == get(choice, optionValue)) !== + undefined // eslint-disable-line eqeqeq + : false + } + value={String(get(choice, optionValue))} + {...options} + /> + } + label={ + translateChoice + ? translate(choiceName, { _: choiceName }) + : choiceName + } + /> + ); +}; + +export default CheckboxGroupInputItem;