From 8459d0071d7aa5ba0063771fa5d4c5c0f868a324 Mon Sep 17 00:00:00 2001 From: Tuan Nguyen Date: Tue, 29 May 2018 16:46:04 +1000 Subject: [PATCH] New: Radio and RadioGroup components --- config/dev.js | 1 + docs/components/Layout/index.jsx | 2 + docs/examples/CheckboxExample.jsx | 14 ++- docs/examples/CheckboxGroupExample.jsx | 24 +++- docs/examples/RadioExample.jsx | 72 +++++++---- docs/examples/RadioGroupExample.jsx | 89 +++++++++++++ src/components/adslot-ui/Checkbox/index.jsx | 50 +++----- .../adslot-ui/CheckboxGroup/index.jsx | 25 ++-- src/components/adslot-ui/Radio/index.jsx | 56 +++++++++ src/components/adslot-ui/Radio/index.spec.jsx | 81 ++++++++++++ src/components/adslot-ui/Radio/styles.scss | 14 +++ src/components/adslot-ui/RadioGroup/index.jsx | 63 ++++++++++ .../adslot-ui/RadioGroup/index.spec.jsx | 119 ++++++++++++++++++ src/components/adslot-ui/index.js | 4 + src/components/prop-types/inputPropTypes.js | 35 ++++++ src/index.js | 4 +- 16 files changed, 581 insertions(+), 72 deletions(-) create mode 100644 docs/examples/RadioGroupExample.jsx create mode 100644 src/components/adslot-ui/Radio/index.jsx create mode 100644 src/components/adslot-ui/Radio/index.spec.jsx create mode 100644 src/components/adslot-ui/Radio/styles.scss create mode 100644 src/components/adslot-ui/RadioGroup/index.jsx create mode 100644 src/components/adslot-ui/RadioGroup/index.spec.jsx create mode 100644 src/components/prop-types/inputPropTypes.js diff --git a/config/dev.js b/config/dev.js index d5e550f5c..eeb95b9f4 100644 --- a/config/dev.js +++ b/config/dev.js @@ -12,6 +12,7 @@ const publicPath = '/docs/assets/'; module.exports = merge(commonConfig, { entry: [ + 'babel-polyfill', // activate HMR for React (needs to be before everything except polyfills) 'react-hot-loader/patch', // bundle the client for webpack-dev-server and connect to the provided endpoint diff --git a/docs/components/Layout/index.jsx b/docs/components/Layout/index.jsx index 3bd7cf8bf..6d6767629 100644 --- a/docs/components/Layout/index.jsx +++ b/docs/components/Layout/index.jsx @@ -16,6 +16,7 @@ import AlertExample from '../../examples/AlertExample'; import CheckboxExample from '../../examples/CheckboxExample'; import CheckboxGroupExample from '../../examples/CheckboxGroupExample'; import RadioExample from '../../examples/RadioExample'; +import RadioGroupExample from '../../examples/RadioGroupExample'; import SelectExample from '../../examples/SelectExample'; import DatePickerExample from '../../examples/DatePickerExample'; import BorderedWellExample from '../../examples/BorderedWellExample'; @@ -178,6 +179,7 @@ class PageLayout extends React.Component { + diff --git a/docs/examples/CheckboxExample.jsx b/docs/examples/CheckboxExample.jsx index 48294ab7e..c06ca1cd8 100644 --- a/docs/examples/CheckboxExample.jsx +++ b/docs/examples/CheckboxExample.jsx @@ -36,6 +36,15 @@ const exampleProps = { { label: '', propTypes: [ + { + propType: 'id', + type: 'string', + }, + { + propType: 'className', + type: 'string', + note: 'This class will be applied to the input element', + }, { propType: 'name', type: 'string', @@ -47,15 +56,16 @@ const exampleProps = { { propType: 'value', type: 'string', - note: 'Required.', }, { propType: 'checked', type: 'bool', + defaultValue: false, }, { propType: 'disabled', type: 'bool', + defaultValue: false, }, { propType: 'dts', @@ -63,7 +73,7 @@ const exampleProps = { }, { propType: 'onChange', - type: 'function', + type: 'func', }, ], }, diff --git a/docs/examples/CheckboxGroupExample.jsx b/docs/examples/CheckboxGroupExample.jsx index 5478af419..e6a215047 100644 --- a/docs/examples/CheckboxGroupExample.jsx +++ b/docs/examples/CheckboxGroupExample.jsx @@ -35,9 +35,22 @@ const exampleProps = { { label: '', propTypes: [ + { + propType: 'id', + type: 'string', + }, + { + propType: 'className', + type: 'string', + }, { propType: 'name', type: 'string', + note: ( + + Required. All Checkboxes within this group will have the same name + + ), }, { propType: 'value', @@ -46,11 +59,18 @@ const exampleProps = { }, { propType: 'children', - type: ' elements', + type: 'arrayOf elements', + note: Required., }, { propType: 'onChange', - type: 'Function', + type: 'func', + note: 'Triggers when selection changes.', + }, + { + propType: 'dts', + type: 'string', + note: 'render `data-test-selector` onto the component. It can be useful for testing.', }, ], }, diff --git a/docs/examples/RadioExample.jsx b/docs/examples/RadioExample.jsx index 4e4f0672f..da9e6dbb1 100644 --- a/docs/examples/RadioExample.jsx +++ b/docs/examples/RadioExample.jsx @@ -1,48 +1,74 @@ import React from 'react'; import Example from '../components/Example'; -import { Radio, RadioGroup } from '../../src'; +import Radio from 'adslot-ui/Radio'; class RadioExample extends React.PureComponent { + onChange(event) { + _.noop(); + } + render() { return ( - - - - + ); } } const exampleProps = { componentName: 'Radio', - designNotes: ( -

- Radio buttons used for making a single selection from multiple options. Only - one selection can ever be made from the radio button group at a time. -

- ), - notes: ( -

- See React iCheck Documentation -

- ), - exampleCodeSnippet: ` - - - - `, + notes: '', + exampleCodeSnippet: ``, propTypeSectionArray: [ { propTypes: [ + { + propType: 'id', + type: 'string', + }, + { + propType: 'className', + type: 'string', + note: 'This class will be applied to the input element', + }, + { + propType: 'name', + type: 'string', + }, { propType: 'label', - type: 'node', - note: 'Usually fine to rely on a string but can pass HTML e.g. for a url.', + type: 'string', }, { propType: 'value', type: 'string', }, + { + propType: 'dts', + type: 'string', + note: 'render `data-test-selector` onto the component. It can be useful for testing.', + }, + { + propType: 'disabled', + type: 'bool', + defaultValue: false, + }, + { + propType: 'checked', + type: 'bool', + defaultValue: false, + }, { propType: 'onChange', type: 'func', diff --git a/docs/examples/RadioGroupExample.jsx b/docs/examples/RadioGroupExample.jsx new file mode 100644 index 000000000..8ca6982df --- /dev/null +++ b/docs/examples/RadioGroupExample.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import Example from '../components/Example'; +import RadioGroup from 'adslot-ui/RadioGroup'; +import Radio from 'adslot-ui/Radio'; + +class RadioGroupExample extends React.PureComponent { + onChangeGroup(event) { + _.noop(); + } + + onChangeIndividual(event) { + _.noop(); + } + + render() { + return ( + + + + + + ); + } +} + +const exampleProps = { + componentName: 'RadioGroup', + designNotes: ( +

+ Radio buttons used for making a single selection from multiple options. Only + one selection can ever be made from the radio button group at a time. +

+ ), + notes: '', + exampleCodeSnippet: ` + + + +`, + propTypeSectionArray: [ + { + propTypes: [ + { + propType: 'id', + type: 'string', + }, + { + propType: 'className', + type: 'string', + }, + { + propType: 'name', + type: 'string', + note: ( + + Required. All Radio buttons within this group will have the same name + + ), + }, + { + propType: 'value', + type: 'string', + note: 'value of the selected radio button, should be one of children values', + }, + { + propType: 'children', + type: 'arrayOf elements', + note: Required., + }, + { + propType: 'onChange', + type: 'func', + note: 'Triggers when selection changes.', + }, + { + propType: 'dts', + type: 'string', + note: 'render `data-test-selector` onto the component. It can be useful for testing.', + }, + ], + }, + ], +}; + +export default () => ( + + + +); diff --git a/src/components/adslot-ui/Checkbox/index.jsx b/src/components/adslot-ui/Checkbox/index.jsx index 0c432b2c5..a56465fae 100644 --- a/src/components/adslot-ui/Checkbox/index.jsx +++ b/src/components/adslot-ui/Checkbox/index.jsx @@ -1,7 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { expandDts } from 'lib/utils'; - +import { expandDts } from '../../../lib/utils'; +import { checkboxPropTypes } from '../../prop-types/inputPropTypes'; import './styles.scss'; class Checkbox extends React.Component { @@ -28,29 +27,25 @@ class Checkbox extends React.Component { } render() { - const { name, value, label, dts } = this.props; - const optional = { - id: this.props.id ? this.props.id : null, - className: this.props.className ? this.props.className : null, + const { name, value, label, dts, disabled, id, className } = this.props; + const checkboxInputProps = { + type: 'checkbox', + name, + checked: this.state.checked, + disabled, + onChange: this.onChangeDefault, + value, + id, + className, + 'data-name': this.props['data-name'], }; - if (this.props['data-name']) { - optional['data-name'] = this.props['data-name']; - } + return ( -
+
@@ -59,18 +54,7 @@ class Checkbox extends React.Component { } } -Checkbox.propTypes = { - id: PropTypes.string, - className: PropTypes.string, - 'data-name': PropTypes.string, - name: PropTypes.string, - label: PropTypes.node, - value: PropTypes.string, - dts: PropTypes.string, - disabled: PropTypes.bool, - checked: PropTypes.bool, - onChange: PropTypes.func, -}; +Checkbox.propTypes = checkboxPropTypes; Checkbox.defaultProps = { dts: '', diff --git a/src/components/adslot-ui/CheckboxGroup/index.jsx b/src/components/adslot-ui/CheckboxGroup/index.jsx index e3a929e4e..2b74a6ee0 100644 --- a/src/components/adslot-ui/CheckboxGroup/index.jsx +++ b/src/components/adslot-ui/CheckboxGroup/index.jsx @@ -1,6 +1,7 @@ import _ from 'lodash'; import React from 'react'; -import PropTypes from 'prop-types'; +import { expandDts } from '../../../lib/utils'; +import { checkboxGroupPropTypes } from '../../prop-types/inputPropTypes'; const mapPropsToState = (props, state) => { const tempState = _.cloneDeep(state); @@ -56,17 +57,21 @@ class CheckboxGroup extends React.Component { } render() { - const classes = `checkbox-group ${this.props.className}`; - return
{this.renderChildren()}
; + const { id, className, dts } = this.props; + const componentProps = { + id, + className: _(['checkbox-group-component', className]) + .compact() + .join(' '), + }; + return ( +
+ {this.renderChildren()} +
+ ); } } -CheckboxGroup.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.string), - children: PropTypes.arrayOf(PropTypes.element).isRequired, - className: PropTypes.string, - onChange: PropTypes.func, -}; +CheckboxGroup.propTypes = checkboxGroupPropTypes; export default CheckboxGroup; diff --git a/src/components/adslot-ui/Radio/index.jsx b/src/components/adslot-ui/Radio/index.jsx new file mode 100644 index 000000000..72ce88c81 --- /dev/null +++ b/src/components/adslot-ui/Radio/index.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { expandDts } from '../../../lib/utils'; +import { radioButtonPropTypes } from '../../prop-types/inputPropTypes'; +import './styles.scss'; + +class RadioButton extends React.Component { + static getDerivedStateFromProps(newProps, prevState) { + return newProps.checked === prevState.checked ? null : { checked: newProps.checked }; + } + + constructor(props) { + super(props); + this.state = { checked: props.checked }; + + this.onChangeDefault = this.onChangeDefault.bind(this); + } + + onChangeDefault(event) { + this.setState({ checked: Boolean(event.target.checked) }); + if (this.props.onChange) { + this.props.onChange(event); + } + } + + render() { + const { name, className, label, dts, disabled, id, value } = this.props; + const radioInputProps = { + type: 'radio', + name, + checked: this.state.checked, + disabled, + onChange: this.onChangeDefault, + value, + id, + className, + 'data-name': this.props['data-name'], + }; + + return ( +
+ + {label ? {label} : null} +
+ ); + } +} + +RadioButton.propTypes = radioButtonPropTypes; + +RadioButton.defaultProps = { + dts: '', + disabled: false, + checked: false, +}; + +export default RadioButton; diff --git a/src/components/adslot-ui/Radio/index.spec.jsx b/src/components/adslot-ui/Radio/index.spec.jsx new file mode 100644 index 000000000..45550e94f --- /dev/null +++ b/src/components/adslot-ui/Radio/index.spec.jsx @@ -0,0 +1,81 @@ +import _ from 'lodash'; +import React from 'react'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; +import Radio from '.'; + +describe('', () => { + let props; + + beforeEach(() => { + props = { + name: 'radio-name', + value: 'radio-value', + label: 'Radio 1', + dts: 'radio-dts', + id: 'radio-id', + className: 'radio-class', + disabled: false, + checked: false, + onChange: sinon.spy(), + }; + }); + + it('should render with props', () => { + const component = shallow(); + expect(component.find('input[type="radio"]')).to.have.length(1); + expect(component.text()).to.equal('Radio 1'); + expect(component.find('[name="radio-name"]')).to.have.length(1); + expect(component.find('[value="radio-value"]')).to.have.length(1); + expect(component.find('[data-test-selector="radio-dts"]')).to.have.length(1); + }); + + it('should not render label if props.label is undefined', () => { + delete props.label; + const component = shallow(); + expect(component.text()).to.equal(''); + }); + + it('should trigger state change and `props.onChange` when change event is triggered', () => { + const component = shallow(); + const event = { target: { checked: true } }; + + expect(component.state('checked')).to.equal(false); + + component.find('input').simulate('change', event); + expect(component.state('checked')).to.equal(true); + expect(props.onChange.calledOnce).to.equal(true); + }); + + it('should still trigger state change when `props.onChange` is not present', () => { + delete props.onChange; + const component = shallow(); + const event = { target: { checked: true } }; + + expect(component.state('checked')).to.equal(false); + + component.find('input').simulate('change', event); + expect(component.state('checked')).to.equal(true); + }); + + it('should override state value when `prop.value` changes', () => { + const component = shallow(); + expect(component.state('checked')).to.equal(false); + + props.checked = true; + component.setProps(props); + expect(component.state('checked')).to.equal(true); + }); + + it('should NOT override state value when other props change', () => { + const component = shallow(); + expect(component.state('checked')).to.equal(false); + + _.assign(props, { + name: 'some-other-name', + label: 'New Label', + }); + component.setProps(props); + expect(component.state('checked')).to.equal(false); + }); +}); diff --git a/src/components/adslot-ui/Radio/styles.scss b/src/components/adslot-ui/Radio/styles.scss new file mode 100644 index 000000000..df35dd595 --- /dev/null +++ b/src/components/adslot-ui/Radio/styles.scss @@ -0,0 +1,14 @@ +.radio-component { + input { + vertical-align: middle; + cursor: pointer; + } + + .radio-label { + vertical-align: middle; + padding-left: 5px; + } + + padding-top: 10px; + padding-bottom: 10px; +} diff --git a/src/components/adslot-ui/RadioGroup/index.jsx b/src/components/adslot-ui/RadioGroup/index.jsx new file mode 100644 index 000000000..570b9011d --- /dev/null +++ b/src/components/adslot-ui/RadioGroup/index.jsx @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import React from 'react'; +import { expandDts } from '../../../lib/utils'; +import { radioGroupPropTypes } from '../../prop-types/inputPropTypes'; + +class RadioGroup extends React.Component { + static getDerivedStateFromProps(nextProps, prevState) { + return nextProps.value === prevState.value ? null : { value: nextProps.value }; + } + + constructor(props) { + super(props); + + this.state = { + value: props.value, + }; + + this.onChangeDefault = this.onChangeDefault.bind(this); + this.renderChildren = this.renderChildren.bind(this); + } + + onChangeDefault(event) { + this.setState({ value: event.target.value }); + if (this.props.onChange) { + this.props.onChange(event); + } + } + + renderChildren() { + return React.Children.map(this.props.children, child => { + const childProps = _.assign({}, child.props, { + name: this.props.name, + checked: this.state.value === child.props.value, + onChange: (...args) => { + if (child.props.onChange) child.props.onChange(...args); + this.onChangeDefault(...args); + }, + }); + + return React.cloneElement(child, childProps); + }); + } + + render() { + const { dts, className, id } = this.props; + const componentProps = { + id, + className: _(['radio-group-component', className]) + .compact() + .join(' '), + }; + + return ( +
+ {this.renderChildren()} +
+ ); + } +} + +RadioGroup.propTypes = radioGroupPropTypes; + +export default RadioGroup; diff --git a/src/components/adslot-ui/RadioGroup/index.spec.jsx b/src/components/adslot-ui/RadioGroup/index.spec.jsx new file mode 100644 index 000000000..3d8e062a7 --- /dev/null +++ b/src/components/adslot-ui/RadioGroup/index.spec.jsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; +import Radio from '../Radio'; +import RadioGroup from '.'; + +describe('', () => { + let props; + + beforeEach(() => { + props = { + id: 'radio-group-id', + className: 'radio-group-custom-class', + name: 'hobbies', + value: 'badminton', + onChange: sinon.spy(), + dts: 'radio-group-dts', + }; + }); + + it('should render with props', () => { + const component = shallow( + + + + + + ); + + expect(component.find(Radio)).to.have.length(3); + expect(component.find('#radio-group-id')).to.have.length(1); + expect(component.find('.radio-group-custom-class')).to.have.length(1); + expect(component.find('[data-test-selector="radio-group-dts"]')).to.have.length(1); + }); + + it('should update state and trigger props.onChange when selection changes', () => { + const component = shallow( + + + + + + ); + + expect(component.state('value')).to.equal('badminton'); + component + .find(Radio) + .at(0) + .simulate('change', { target: { value: 'swimming' } }); + expect(component.state('value')).to.equal('swimming'); + expect(props.onChange.calledOnce).to.equal(true); + }); + + it('should still update state when props.onChange is not available', () => { + delete props.onChange; + const component = shallow( + + + + + + ); + + expect(component.state('value')).to.equal('badminton'); + component + .find(Radio) + .at(1) + .simulate('change', { target: { value: 'soccer' } }); + expect(component.state('value')).to.equal('soccer'); + }); + + it('should call Radio props.onChange when selecting that radio button', () => { + const onChangeSwimming = sinon.spy(); + const onChangeSoccer = sinon.spy(); + + const component = shallow( + + + + + + ); + + component + .find(Radio) + .at(0) + .simulate('change', { target: { value: 'swimming' } }); + expect(onChangeSwimming.calledOnce).to.equal(true); + expect(onChangeSoccer.called).to.equal(false); + expect(props.onChange.calledOnce).to.equal(true); + }); + + describe('getDerivedStateFromProps()', () => { + let component; + + beforeEach(() => { + component = shallow( + + + + + + ); + }); + + it('should update selected value when `prop.value` changes', () => { + props.value = 'soccer'; + component.setProps(props); + expect(component.state('value')).to.equal('soccer'); + }); + + it('should NOT update selected value when other props attributes change', () => { + props.id = 'new-id'; + props.name = 'some-other-name'; + component.setProps(props); + expect(component.state('value')).to.equal('badminton'); + }); + }); +}); diff --git a/src/components/adslot-ui/index.js b/src/components/adslot-ui/index.js index de528cda9..eb8841557 100644 --- a/src/components/adslot-ui/index.js +++ b/src/components/adslot-ui/index.js @@ -11,6 +11,8 @@ import ListPicker from 'adslot-ui/ListPicker'; import ListPickerPure from 'adslot-ui/ListPickerPure'; import PagedGrid from 'adslot-ui/PagedGrid'; import Panel from 'adslot-ui/Panel'; +import Radio from 'adslot-ui/Radio'; +import RadioGroup from 'adslot-ui/RadioGroup'; import Search from 'adslot-ui/Search'; import SearchBar from 'adslot-ui/SearchBar'; import SplitPane from 'adslot-ui/SplitPane'; @@ -42,6 +44,8 @@ export { Nav, PagedGrid, Panel, + Radio, + RadioGroup, Search, SearchBar, SplitPane, diff --git a/src/components/prop-types/inputPropTypes.js b/src/components/prop-types/inputPropTypes.js new file mode 100644 index 000000000..0ac7b86bf --- /dev/null +++ b/src/components/prop-types/inputPropTypes.js @@ -0,0 +1,35 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; + +// common input tag props +export const inputPropTypes = { + id: PropTypes.string, + className: PropTypes.string, + name: PropTypes.string, + label: PropTypes.node, + value: PropTypes.string, + dts: PropTypes.string, + disabled: PropTypes.bool, + onChange: PropTypes.func, +}; + +export const checkboxPropTypes = _.assign({}, inputPropTypes, { + checked: PropTypes.bool, + 'data-name': PropTypes.string, +}); + +export const radioButtonPropTypes = checkboxPropTypes; + +export const checkboxGroupPropTypes = { + id: PropTypes.string, + className: PropTypes.string, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.string), + children: PropTypes.arrayOf(PropTypes.element).isRequired, + onChange: PropTypes.func, + dts: PropTypes.string, +}; + +export const radioGroupPropTypes = _.assign({}, checkboxGroupPropTypes, { + value: PropTypes.string, +}); diff --git a/src/index.js b/src/index.js index f032877a3..45075ffd0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,4 @@ // Export the consumable components. -import Radio from 'react-icheck/lib/Radio'; -import RadioGroup from 'react-icheck/lib/RadioGroup'; import Select from 'react-select'; import DatePicker from 'react-datepicker'; @@ -63,6 +61,8 @@ import { Nav, PagedGrid, Panel, + Radio, + RadioGroup, Search, SearchBar, SplitPane,