From 20325bc2c43bbaac644f2294f840bbd97457f668 Mon Sep 17 00:00:00 2001 From: Jess Thrysoee Date: Sat, 11 Mar 2017 15:37:06 +0100 Subject: [PATCH] [SelectField] [DropDownMenu] Support multi select (#6165) * [SelectField] Add multi select to SelectField and DropDownMenu Make `Menu`s multi select support available in SelectField and DropDownMenu. Add `multiple: PropTypes.bool` to SelectField and DropDownMenu, and document that if true, value props and value/payload onChange parameters are arrays. * [SelectField] Add onChangeRenderer Make it possible to change the default comma separated rendering * rename onChangeRenderer to selectionRenderer * Add ExampleMultiSelect docs example * Handle multiple value initialized to null * Adhere to naming convension: handleOnChange -> handleChange * Fix lint errors * Add multi select test to DropDownMenu * Add multi select test to Menu * Add multi select test to SelectField * Handle initial null menuValue * Always pass value when a selectionRenderer is provided. * Avoid function indirection and destructuration overkill * Remove blank lines from render * Assert state before and after deselecting item1 * Cleanup component by unmounting wrapper in afterEach * Add SelectField selectionRenderer test * require react-tap-event-plugin for karma tests * Only persist event when necessary. * Add a little documentation * Add ExampleSelectionRenderer * Remove multiple guard in handleTouchTapControl The ClearFix component is covered by the Popover so it is not possible to interact with it. --- .../SelectField/ExampleMultiSelect.js | 53 +++++++++ .../SelectField/ExampleSelectionRenderer.js | 64 +++++++++++ .../pages/components/SelectField/Page.js | 16 +++ src/DropDownMenu/DropDownMenu.js | 90 ++++++++++++--- src/DropDownMenu/DropDownMenu.spec.js | 54 ++++++++- src/Menu/Menu.js | 6 +- src/Menu/Menu.spec.js | 44 +++++++- src/SelectField/SelectField.js | 31 +++++- src/SelectField/SelectField.spec.js | 103 +++++++++++++++++- src/TextField/TextField.js | 2 +- test/karma.tests.js | 2 + 11 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 docs/src/app/components/pages/components/SelectField/ExampleMultiSelect.js create mode 100644 docs/src/app/components/pages/components/SelectField/ExampleSelectionRenderer.js diff --git a/docs/src/app/components/pages/components/SelectField/ExampleMultiSelect.js b/docs/src/app/components/pages/components/SelectField/ExampleMultiSelect.js new file mode 100644 index 00000000000000..4ee7ed9ae5b281 --- /dev/null +++ b/docs/src/app/components/pages/components/SelectField/ExampleMultiSelect.js @@ -0,0 +1,53 @@ +import React, {Component} from 'react'; +import SelectField from 'material-ui/SelectField'; +import MenuItem from 'material-ui/MenuItem'; + +const names = [ + 'Oliver Hansen', + 'Van Henry', + 'April Tucker', + 'Ralph Hubbard', + 'Omar Alexander', + 'Carlos Abbott', + 'Miriam Wagner', + 'Bradley Wilkerson', + 'Virginia Andrews', + 'Kelly Snyder', +]; + +/** + * `SelectField` can handle multiple selections. It is enabled with the `multiple` property. + */ +export default class SelectFieldExampleMultiSelect extends Component { + state = { + values: [], + }; + + handleChange = (event, index, values) => this.setState({values}); + + menuItems(values) { + return names.map((name) => ( + + )); + } + + render() { + const {values} = this.state; + return ( + + {this.menuItems(values)} + + ); + } +} diff --git a/docs/src/app/components/pages/components/SelectField/ExampleSelectionRenderer.js b/docs/src/app/components/pages/components/SelectField/ExampleSelectionRenderer.js new file mode 100644 index 00000000000000..1b4e5f3eca1873 --- /dev/null +++ b/docs/src/app/components/pages/components/SelectField/ExampleSelectionRenderer.js @@ -0,0 +1,64 @@ +import React, {Component} from 'react'; +import SelectField from 'material-ui/SelectField'; +import MenuItem from 'material-ui/MenuItem'; + +const persons = [ + {value: 0, name: 'Oliver Hansen'}, + {value: 1, name: 'Van Henry'}, + {value: 2, name: 'April Tucker'}, + {value: 3, name: 'Ralph Hubbard'}, + {value: 4, name: 'Omar Alexander'}, + {value: 5, name: 'Carlos Abbott'}, + {value: 6, name: 'Miriam Wagner'}, + {value: 7, name: 'Bradley Wilkerson'}, + {value: 8, name: 'Virginia Andrews'}, + {value: 9, name: 'Kelly Snyder'}, +]; + +/** + * The rendering of selected items can be customized by providing a `selectionRenderer`. + */ +export default class SelectFieldExampleSelectionRenderer extends Component { + state = { + values: [], + }; + + handleChange = (event, index, values) => this.setState({values}); + + selectionRenderer = (values) => { + switch (values.length) { + case 0: + return ''; + case 1: + return persons[values[0]].name; + default: + return `${values.length} names selected`; + } + } + + menuItems(persons) { + return persons.map((person) => ( + + )); + } + + render() { + return ( + + {this.menuItems(persons)} + + ); + } +} diff --git a/docs/src/app/components/pages/components/SelectField/Page.js b/docs/src/app/components/pages/components/SelectField/Page.js index 9adb6c7e841109..f2812ad4b8d942 100644 --- a/docs/src/app/components/pages/components/SelectField/Page.js +++ b/docs/src/app/components/pages/components/SelectField/Page.js @@ -18,6 +18,10 @@ import SelectFieldExampleError from './ExampleError'; import selectFieldExampleErrorCode from '!raw!./ExampleError'; import SelectFieldExampleNullable from './ExampleNullable'; import SelectFieldExampleNullableCode from '!raw!./ExampleNullable'; +import SelectFieldExampleMultiSelect from './ExampleMultiSelect'; +import selectFieldExampleMultiSelectCode from '!raw!./ExampleMultiSelect'; +import SelectFieldExampleSelectionRenderer from './ExampleSelectionRenderer'; +import selectFieldExampleSelectionRendererCode from '!raw!./ExampleSelectionRenderer'; import selectFieldCode from '!raw!material-ui/SelectField/SelectField'; const SelectFieldPage = () => ( @@ -60,6 +64,18 @@ const SelectFieldPage = () => ( > + + + + + + ); diff --git a/src/DropDownMenu/DropDownMenu.js b/src/DropDownMenu/DropDownMenu.js index 4524142ba3df38..b3b1f37b0e12f5 100644 --- a/src/DropDownMenu/DropDownMenu.js +++ b/src/DropDownMenu/DropDownMenu.js @@ -139,12 +139,20 @@ class DropDownMenu extends Component { * Overrides the styles of `Menu` when the `DropDownMenu` is displayed. */ menuStyle: PropTypes.object, + /** + * If true, `value` must be an array and the menu will support + * multiple selections. + */ + multiple: PropTypes.bool, /** * Callback function fired when a menu item is clicked, other than the one currently selected. * * @param {object} event TouchTap event targeting the menu item that was clicked. * @param {number} key The index of the clicked menu item in the `children` collection. - * @param {any} payload The `value` prop of the clicked menu item. + * @param {any} value If `multiple` is true, the menu's `value` + * array with either the menu item's `value` added (if + * it wasn't already selected) or omitted (if it was already selected). + * Otherwise, the `value` of the menu item. */ onChange: PropTypes.func, /** @@ -159,6 +167,15 @@ class DropDownMenu extends Component { * Override the inline-styles of selected menu items. */ selectedMenuItemStyle: PropTypes.object, + /** + * Callback function fired when a menu item is clicked, other than the one currently selected. + * + * @param {any} value If `multiple` is true, the menu's `value` + * array with either the menu item's `value` added (if + * it wasn't already selected) or omitted (if it was already selected). + * Otherwise, the `value` of the menu item. + */ + selectionRenderer: PropTypes.func, /** * Override the inline-styles of the root element. */ @@ -168,7 +185,9 @@ class DropDownMenu extends Component { */ underlineStyle: PropTypes.object, /** - * The value that is currently selected. + * If `multiple` is true, an array of the `value`s of the selected + * menu items. Otherwise, the `value` of the selected menu item. + * If provided, the menu will be a controlled component. */ value: PropTypes.any, }; @@ -180,6 +199,7 @@ class DropDownMenu extends Component { iconButton: , openImmediately: false, maxHeight: 500, + multiple: false, }; static contextTypes = { @@ -274,16 +294,28 @@ class DropDownMenu extends Component { }; handleItemTouchTap = (event, child, index) => { - event.persist(); - this.setState({ - open: false, - }, () => { - if (this.props.onChange) { - this.props.onChange(event, index, child.props.value); + if (this.props.multiple) { + if (!this.state.open) { + this.setState({open: true}); } + } else { + event.persist(); + this.setState({ + open: false, + }, () => { + if (this.props.onChange) { + this.props.onChange(event, index, child.props.value); + } - this.close(Events.isKeyboard(event)); - }); + this.close(Events.isKeyboard(event)); + }); + } + }; + + handleChange = (event, value) => { + if (this.props.multiple && this.props.onChange) { + this.props.onChange(event, undefined, value); + } }; close = (isKeyboard) => { @@ -308,6 +340,7 @@ class DropDownMenu extends Component { animated, animation, autoWidth, + multiple, children, className, disabled, @@ -316,6 +349,7 @@ class DropDownMenu extends Component { listStyle, maxHeight, menuStyle: menuStyleProp, + selectionRenderer, onClose, // eslint-disable-line no-unused-vars openImmediately, // eslint-disable-line no-unused-vars menuItemStyle, @@ -335,12 +369,36 @@ class DropDownMenu extends Component { const styles = getStyles(this.props, this.context); let displayValue = ''; - React.Children.forEach(children, (child) => { - if (child && value === child.props.value) { - // This will need to be improved (in case primaryText is a node) - displayValue = child.props.label || child.props.primaryText; + if (!multiple) { + React.Children.forEach(children, (child) => { + if (child && value === child.props.value) { + if (selectionRenderer) { + displayValue = selectionRenderer(value); + } else { + // This will need to be improved (in case primaryText is a node) + displayValue = child.props.label || child.props.primaryText; + } + } + }); + } else { + const values = []; + React.Children.forEach(children, (child) => { + if (child && value && value.includes(child.props.value)) { + if (selectionRenderer) { + values.push(child.props.value); + } else { + values.push(child.props.label || child.props.primaryText); + } + } + }); + + displayValue = []; + if (selectionRenderer) { + displayValue = selectionRenderer(values); + } else { + displayValue = values.join(', '); } - }); + } let menuStyle; if (anchorEl && !autoWidth) { @@ -386,6 +444,7 @@ class DropDownMenu extends Component { onRequestClose={this.handleRequestCloseMenu} > diff --git a/src/DropDownMenu/DropDownMenu.spec.js b/src/DropDownMenu/DropDownMenu.spec.js index fc358b0866ef7e..bc2b48f4571828 100644 --- a/src/DropDownMenu/DropDownMenu.spec.js +++ b/src/DropDownMenu/DropDownMenu.spec.js @@ -1,5 +1,5 @@ /* eslint-env mocha */ -import React, {PropTypes} from 'react'; +import React, {PropTypes, Component} from 'react'; import {shallow, mount} from 'enzyme'; import {assert} from 'chai'; import keycode from 'keycode'; @@ -9,6 +9,7 @@ import getMuiTheme from '../styles/getMuiTheme'; import MenuItem from '../MenuItem'; import Menu from '../Menu/Menu'; import IconButton from '../IconButton'; +import TestUtils from 'react-addons-test-utils'; describe('', () => { const muiTheme = getMuiTheme(); @@ -185,4 +186,55 @@ describe('', () => { assert.strictEqual(wrapper.state().open, true, 'it should open the menu'); }); }); + + describe('MultiSelect', () => { + let wrapper; + + it('should multi select 2 items after selecting 3 and deselecting 1', () => { + class MyComponent1 extends Component { + state = { + value: null, + } + + handleChange = (event, key, value) => { + this.setState({value}); + } + + render() { + return ( + + + + + + ); + } + } + wrapper = mountWithContext(); + wrapper.find('IconButton').simulate('touchTap'); // open + + const item1 = document.getElementsByClassName('item1')[0]; + assert.ok(item1); + const item2 = document.getElementsByClassName('item2')[0]; + assert.ok(item2); + const item3 = document.getElementsByClassName('item3')[0]; + assert.ok(item3); + + TestUtils.Simulate.touchTap(item1); + TestUtils.Simulate.touchTap(item2); + TestUtils.Simulate.touchTap(item3); + assert.deepEqual(wrapper.state().value, ['item1', 'item2', 'item3']); + + TestUtils.Simulate.touchTap(item1); // deselect + assert.deepEqual(wrapper.state().value, ['item2', 'item3']); + }); + + afterEach(function() { + if (wrapper) wrapper.unmount(); + }); + }); }); diff --git a/src/Menu/Menu.js b/src/Menu/Menu.js index 059a1429cb2a99..6e6d06016d7602 100644 --- a/src/Menu/Menu.js +++ b/src/Menu/Menu.js @@ -381,13 +381,15 @@ class Menu extends Component { const children = this.props.children; const multiple = this.props.multiple; const valueLink = this.getValueLink(this.props); - const menuValue = valueLink.value; + let menuValue = valueLink.value; const itemValue = item.props.value; const focusIndex = React.isValidElement(children) ? 0 : children.indexOf(item); this.setFocusIndex(event, focusIndex, false); if (multiple) { + menuValue = menuValue || []; + const itemIndex = menuValue.indexOf(itemValue); const [...newMenuValue] = menuValue; if (itemIndex === -1) { @@ -419,7 +421,7 @@ class Menu extends Component { const childValue = child.props.value; if (props.multiple) { - return menuValue.length && menuValue.indexOf(childValue) !== -1; + return menuValue && menuValue.length && menuValue.indexOf(childValue) !== -1; } else { return child.props.hasOwnProperty('value') && menuValue === childValue; } diff --git a/src/Menu/Menu.spec.js b/src/Menu/Menu.spec.js index d0ce62b1f0eb83..b9b4bf56b604cb 100644 --- a/src/Menu/Menu.spec.js +++ b/src/Menu/Menu.spec.js @@ -1,5 +1,5 @@ /* eslint-env mocha */ -import React from 'react'; +import React, {PropTypes, Component} from 'react'; import {mount, shallow} from 'enzyme'; import {spy} from 'sinon'; import {assert} from 'chai'; @@ -12,7 +12,10 @@ import keycode from 'keycode'; describe('', () => { const muiTheme = getMuiTheme(); const shallowWithContext = (node) => shallow(node, {context: {muiTheme}}); - const mountWithContext = (node) => mount(node, {context: {muiTheme}}); + const mountWithContext = (node) => mount(node, { + context: {muiTheme}, + childContextTypes: {muiTheme: PropTypes.object}, + }); const keycodeEvent = (key) => ({keyCode: keycode(key)}); describe('onMenuItemFocusChange', () => { @@ -213,4 +216,41 @@ describe('', () => { assert.strictEqual(wrapper.contains(child), true); }); }); + + describe('MultiSelect', () => { + it('should multi select 2 items after selecting 3 and deselecting 1', () => { + class MyComponent1 extends Component { + state = { + value: null, + } + + handleChange = (event, value) => { + this.setState({value: value}); + } + + render() { + return ( + + + + + + ); + } + } + + const wrapper = mountWithContext(); + + wrapper.find('.item1').simulate('touchTap'); + wrapper.find('.item2').simulate('touchTap'); + wrapper.find('.item3').simulate('touchTap'); + wrapper.find('.item1').simulate('touchTap'); // deselect + + assert.deepEqual(wrapper.state().value, ['item2', 'item3']); + }); + }); }); diff --git a/src/SelectField/SelectField.js b/src/SelectField/SelectField.js index 91912981d862c8..d15388b170ad30 100644 --- a/src/SelectField/SelectField.js +++ b/src/SelectField/SelectField.js @@ -99,6 +99,11 @@ class SelectField extends Component { * Override the inline-styles of the underlying `DropDownMenu` element. */ menuStyle: PropTypes.object, + /** + * If true, `value` must be an array and the menu will support + * multiple selections. + */ + multiple: PropTypes.bool, /** @ignore */ onBlur: PropTypes.func, /** @@ -106,8 +111,12 @@ class SelectField extends Component { * * @param {object} event TouchTap event targeting the menu item * that was selected. - * @param {number} key The index of the selected menu item. - * @param {any} payload The `value` prop of the selected menu item. + * @param {number} key The index of the selected menu item, or undefined + * if `multiple` is true. + * @param {any} payload If `multiple` is true, the menu's `value` + * array with either the menu item's `value` added (if + * it wasn't already selected) or omitted (if it was already selected). + * Otherwise, the `value` of the menu item. */ onChange: PropTypes.func, /** @ignore */ @@ -116,6 +125,15 @@ class SelectField extends Component { * Override the inline-styles of selected menu items. */ selectedMenuItemStyle: PropTypes.object, + /** + * Customize the rendering of the selected item. + * + * @param {any} value If `multiple` is true, the menu's `value` + * array with either the menu item's `value` added (if + * it wasn't already selected) or omitted (if it was already selected). + * Otherwise, the `value` of the menu item. + */ + selectionRenderer: PropTypes.func, /** * Override the inline-styles of the root element. */ @@ -135,7 +153,9 @@ class SelectField extends Component { */ underlineStyle: PropTypes.object, /** - * The value that is currently selected. + * If `multiple` is true, an array of the `value`s of the selected + * menu items. Otherwise, the `value` of the selected menu item. + * If provided, the menu will be a controlled component. */ value: PropTypes.any, }; @@ -144,6 +164,7 @@ class SelectField extends Component { autoWidth: false, disabled: false, fullWidth: false, + multiple: false, }; static contextTypes = { @@ -153,6 +174,7 @@ class SelectField extends Component { render() { const { autoWidth, + multiple, children, style, labelStyle, @@ -178,6 +200,7 @@ class SelectField extends Component { onFocus, onBlur, onChange, + selectionRenderer, value, ...other } = this.props; @@ -217,6 +240,8 @@ class SelectField extends Component { value={value} onChange={onChange} maxHeight={maxHeight} + multiple={multiple} + selectionRenderer={selectionRenderer} > {children} diff --git a/src/SelectField/SelectField.spec.js b/src/SelectField/SelectField.spec.js index f4ce59b74be2a0..91db6181ad82a8 100644 --- a/src/SelectField/SelectField.spec.js +++ b/src/SelectField/SelectField.spec.js @@ -1,10 +1,12 @@ /* eslint-env mocha */ -import React, {PropTypes} from 'react'; +import React, {PropTypes, Component} from 'react'; import {mount} from 'enzyme'; import {assert} from 'chai'; import getMuiTheme from '../styles/getMuiTheme'; import SelectField from './SelectField'; import TouchRipple from '../internal/TouchRipple'; +import MenuItem from '../MenuItem'; +import TestUtils from 'react-addons-test-utils'; describe('', () => { const muiTheme = getMuiTheme(); @@ -21,4 +23,103 @@ describe('', () => { assert.strictEqual(wrapper.find(TouchRipple).length, 0, 'should not contain a TouchRipple'); }); }); + + describe('MultiSelect', () => { + let wrapper; + + it('should multi select 2 items after selecting 3 and deselecting 1', () => { + class MyComponent2 extends Component { + state = { + value: null, + } + + handleChange = (event, key, value) => { + this.setState({value}); + } + + render() { + return ( + + + + + + ); + } + } + wrapper = mountWithContext(); + wrapper.find('IconButton').simulate('touchTap'); // open + + const item1 = document.getElementsByClassName('item1')[0]; + assert.ok(item1); + const item2 = document.getElementsByClassName('item2')[0]; + assert.ok(item2); + const item3 = document.getElementsByClassName('item3')[0]; + assert.ok(item3); + + TestUtils.Simulate.touchTap(item1); + TestUtils.Simulate.touchTap(item2); + TestUtils.Simulate.touchTap(item3); + assert.deepEqual(wrapper.state().value, ['item1', 'item2', 'item3']); + + TestUtils.Simulate.touchTap(item1); // deselect + assert.deepEqual(wrapper.state().value, ['item2', 'item3']); + }); + + it('should multi select 3 items and render their values colon separated', () => { + class MyComponent2 extends Component { + state = { + value: null, + } + + selectionRenderer(value) { + return {value.join(';')}; + } + + handleChange = (event, key, value) => { + this.setState({value}); + } + + render() { + return ( + + + + + + ); + } + } + wrapper = mountWithContext(); + wrapper.find('IconButton').simulate('touchTap'); // open + + const item1 = document.getElementsByClassName('item1')[0]; + assert.ok(item1); + const item2 = document.getElementsByClassName('item2')[0]; + assert.ok(item2); + const item3 = document.getElementsByClassName('item3')[0]; + assert.ok(item3); + + TestUtils.Simulate.touchTap(item1); + TestUtils.Simulate.touchTap(item2); + TestUtils.Simulate.touchTap(item3); + assert.deepEqual(wrapper.state().value, ['item1', 'item2', 'item3']); + + wrapper.find('IconButton').simulate('touchTap'); // close + assert.deepEqual(wrapper.find('#selection1').text(), 'item1;item2;item3'); + }); + + afterEach(function() { + if (wrapper) wrapper.unmount(); + }); + }); }); diff --git a/src/TextField/TextField.js b/src/TextField/TextField.js index cdb9d9b25c0c57..49fc10ea07d275 100644 --- a/src/TextField/TextField.js +++ b/src/TextField/TextField.js @@ -105,7 +105,7 @@ const getStyles = (props, context, state) => { * @returns True if the string provided is valid, false otherwise. */ function isValid(value) { - return value !== '' && value !== undefined && value !== null; + return value !== '' && value !== undefined && value !== null && !(Array.isArray(value) && value.length === 0); } class TextField extends Component { diff --git a/test/karma.tests.js b/test/karma.tests.js index 93632fe4f1d4fb..9eb4e3192ac044 100644 --- a/test/karma.tests.js +++ b/test/karma.tests.js @@ -1,3 +1,5 @@ +const injectTapEventPlugin = require('react-tap-event-plugin'); +injectTapEventPlugin(); const integrationContext = require.context('./integration', true, /\.(js|jsx)$/); integrationContext.keys().forEach(integrationContext); const unitContext = require.context('../src/', true, /\.spec\.(js|jsx)$/);