From 1f0f8ede96bb4981c70c0c6bf48d60a0368affde 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 --- docs/components/Layout/index.jsx | 2 + docs/examples/RadioExample.jsx | 74 +++++++---- docs/examples/RadioGroupExample.jsx | 89 +++++++++++++ src/components/adslot-ui/Checkbox/index.jsx | 18 +-- .../adslot-ui/CheckboxGroup/index.jsx | 26 ++-- src/components/adslot-ui/Radio/index.jsx | 57 +++++++++ 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 +- 13 files changed, 535 insertions(+), 51 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/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/RadioExample.jsx b/docs/examples/RadioExample.jsx index 4e4f0672f..1ecd58483 100644 --- a/docs/examples/RadioExample.jsx +++ b/docs/examples/RadioExample.jsx @@ -1,52 +1,78 @@ 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', + 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', }, + { + propType: 'value', + type: 'string', + }, ], }, ], diff --git a/docs/examples/RadioGroupExample.jsx b/docs/examples/RadioGroupExample.jsx new file mode 100644 index 000000000..c1fce4fb3 --- /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: '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', + }, + { + propType: 'onChange', + type: 'func', + note: 'Triggers when selection changes.', + }, + { + propType: 'children', + type: 'node', + note: `Should be an array of components`, + }, + { + propType: 'className', + type: 'string', + }, + { + propType: 'dts', + type: 'string', + note: 'render `data-test-selector` onto the component. It can be useful for testing.', + }, + { + propType: 'id', + type: 'string', + }, + ], + }, + ], +}; + +export default () => ( + + + +); diff --git a/src/components/adslot-ui/Checkbox/index.jsx b/src/components/adslot-ui/Checkbox/index.jsx index b0df03eb9..a22b84508 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 { @@ -56,18 +55,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..69a47f91e 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,22 @@ 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..9c2d6adaf --- /dev/null +++ b/src/components/adslot-ui/Radio/index.jsx @@ -0,0 +1,57 @@ +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..579d9b54f --- /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 = _.pickBy({ + 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..76cd259d5 --- /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, + value: PropTypes.string, + dts: PropTypes.string, + disabled: PropTypes.bool, + label: PropTypes.node, + 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, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.string), + children: PropTypes.arrayOf(PropTypes.element).isRequired, + className: PropTypes.string, + 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,