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/Radio/index.jsx b/src/components/adslot-ui/Radio/index.jsx new file mode 100644 index 000000000..7ddf57e82 --- /dev/null +++ b/src/components/adslot-ui/Radio/index.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { expandDts } from 'lib/utils'; + +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, + }; + + return ( +
+ + {label ? {label} : null} +
+ ); + } +} + +RadioButton.propTypes = { + id: PropTypes.string, + className: PropTypes.string, + name: PropTypes.string, + label: PropTypes.node, + dts: PropTypes.string, + disabled: PropTypes.bool, + checked: PropTypes.bool, + onChange: PropTypes.func, + value: PropTypes.string, +}; + +RadioButton.defaultProps = { + 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/RadioGroup/index.jsx b/src/components/adslot-ui/RadioGroup/index.jsx new file mode 100644 index 000000000..afd778d91 --- /dev/null +++ b/src/components/adslot-ui/RadioGroup/index.jsx @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { expandDts } from 'lib/utils'; + +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 props = _.pickBy({ + id, + className: _(['radio-group-component', className]) + .compact() + .join(' '), + }); + + return ( +
+ {this.renderChildren()} +
+ ); + } +} + +RadioGroup.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string, + onChange: PropTypes.func, + children: PropTypes.node, + className: PropTypes.string, + dts: PropTypes.string, + id: PropTypes.string, +}; + +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/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,