diff --git a/src/lib/AutoControlledComponent.js b/src/lib/AutoControlledComponent.js deleted file mode 100644 index 1e995ab32a..0000000000 --- a/src/lib/AutoControlledComponent.js +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable no-console */ -/** - * Why choose inheritance over a HOC? Multiple advantages for this particular use case. - * In short, we need identical functionality to setState(), unless there is a prop defined - * for the state key. Also: - * - * 1. Single Renders - * Calling trySetState() in constructor(), componentWillMount(), or componentWillReceiveProps() - * does not cause two renders. Consumers and tests do not have to wait two renders to get state. - * See www.react.run/4kJFdKoxb/27 for an example of this issue. - * - * 2. Simple Testing - * Using a HOC means you must either test the undecorated component or test through the decorator. - * Testing the undecorated component means you must mock the decorator functionality. - * Testing through the HOC means you can not simply shallow render your component. - * - * 3. Statics - * HOC wrap instances, so statics are no longer accessible. They can be hoisted, but this is more - * looping over properties and storing references. We rely heavily on statics for testing and sub - * components. - * - * 4. Instance Methods - * Some instance methods may be exposed to users via refs. Again, these are lost with HOC unless - * hoisted and exposed by the HOC. - */ -import _ from 'lodash' -import { Component } from 'react' - -export const getDefaultPropName = (prop) => `default${prop[0].toUpperCase() + prop.slice(1)}` - -/** - * Return the auto controlled state value for a give prop. The initial value is chosen in this order: - * - regular props - * - then, default props - * - then, initial state - * - then, `checked` defaults to false - * - then, `value` defaults to '' or [] if props.multiple - * - else, undefined - * - * @param {string} propName A prop name - * @param {object} [props] A props object - * @param {object} [state] A state object - * @param {boolean} [includeDefaults=false] Whether or not to heed the default props or initial state - */ -export const getAutoControlledStateValue = (propName, props, state, includeDefaults = false) => { - // regular props - const propValue = props[propName] - if (propValue !== undefined) return propValue - - if (includeDefaults) { - // defaultProps - const defaultProp = props[getDefaultPropName(propName)] - if (defaultProp !== undefined) return defaultProp - - // initial state - state may be null or undefined - if (state) { - const initialState = state[propName] - if (initialState !== undefined) return initialState - } - } - - // React doesn't allow changing from uncontrolled to controlled components, - // default checked/value if they were not present. - if (propName === 'checked') return false - if (propName === 'value') return props.multiple ? [] : '' - - // otherwise, undefined -} - -export default class AutoControlledComponent extends Component { - constructor(...args) { - super(...args) - - const { autoControlledProps } = this.constructor - const state = _.invoke(this, 'getInitialAutoControlledState', this.props) || {} - - if (process.env.NODE_ENV !== 'production') { - const { defaultProps, name, propTypes } = this.constructor - // require static autoControlledProps - if (!autoControlledProps) { - console.error(`Auto controlled ${name} must specify a static autoControlledProps array.`) - } - - // require propTypes - _.each(autoControlledProps, (prop) => { - const defaultProp = getDefaultPropName(prop) - // regular prop - if (!_.has(propTypes, defaultProp)) { - console.error( - `${name} is missing "${defaultProp}" propTypes validation for auto controlled prop "${prop}".`, - ) - } - // its default prop - if (!_.has(propTypes, prop)) { - console.error( - `${name} is missing propTypes validation for auto controlled prop "${prop}".`, - ) - } - }) - - // prevent autoControlledProps in defaultProps - // - // When setting state, auto controlled props values always win (so the parent can manage them). - // It is not reasonable to decipher the difference between props from the parent and defaultProps. - // Allowing defaultProps results in trySetState always deferring to the defaultProp value. - // Auto controlled props also listed in defaultProps can never be updated. - // - // To set defaults for an AutoControlled prop, you can set the initial state in the - // constructor or by using an ES7 property initializer: - // https://babeljs.io/blog/2015/06/07/react-on-es6-plus#property-initializers - const illegalDefaults = _.intersection(autoControlledProps, _.keys(defaultProps)) - if (!_.isEmpty(illegalDefaults)) { - console.error( - [ - 'Do not set defaultProps for autoControlledProps. You can set defaults by', - 'setting state in the constructor or using an ES7 property initializer', - '(https://babeljs.io/blog/2015/06/07/react-on-es6-plus#property-initializers)', - `See ${name} props: "${illegalDefaults}".`, - ].join(' '), - ) - } - - // prevent listing defaultProps in autoControlledProps - // - // Default props are automatically handled. - // Listing defaults in autoControlledProps would result in allowing defaultDefaultValue props. - const illegalAutoControlled = _.filter(autoControlledProps, (prop) => - _.startsWith(prop, 'default'), - ) - if (!_.isEmpty(illegalAutoControlled)) { - console.error( - [ - 'Do not add default props to autoControlledProps.', - 'Default props are automatically handled.', - `See ${name} autoControlledProps: "${illegalAutoControlled}".`, - ].join(' '), - ) - } - } - - // Auto controlled props are copied to state. - // Set initial state by copying auto controlled props to state. - // Also look for the default prop for any auto controlled props (foo => defaultFoo) - // so we can set initial values from defaults. - const initialAutoControlledState = autoControlledProps.reduce((acc, prop) => { - acc[prop] = getAutoControlledStateValue(prop, this.props, state, true) - - if (process.env.NODE_ENV !== 'production') { - const defaultPropName = getDefaultPropName(prop) - const { name } = this.constructor - // prevent defaultFoo={} along side foo={} - if (!_.isUndefined(this.props[defaultPropName]) && !_.isUndefined(this.props[prop])) { - console.error( - `${name} prop "${prop}" is auto controlled. Specify either ${defaultPropName} or ${prop}, but not both.`, - ) - } - } - - return acc - }, {}) - - this.state = { ...state, ...initialAutoControlledState } - } - - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) { - const { autoControlledProps } = this.constructor - - // Solve the next state for autoControlledProps - const newState = autoControlledProps.reduce((acc, prop) => { - const isNextDefined = !_.isUndefined(nextProps[prop]) - - // if next is defined then use its value - if (isNextDefined) acc[prop] = nextProps[prop] - - return acc - }, {}) - - if (Object.keys(newState).length > 0) this.setState(newState) - } - - /** - * Safely attempt to set state for props that might be controlled by the user. - * Second argument is a state object that is always passed to setState. - * @param {object} state State that corresponds to controlled props. - * @param {function} [callback] Callback which is called after setState applied. - */ - trySetState = (state, callback) => { - const newState = Object.keys(state).reduce((acc, prop) => { - // ignore props defined by the parent - if (this.props[prop] !== undefined) return acc - - acc[prop] = state[prop] - return acc - }, {}) - - if (Object.keys(newState).length > 0) this.setState(newState, callback) - } -} diff --git a/src/lib/ModernAutoControlledComponent.js b/src/lib/ModernAutoControlledComponent.js index bad6711695..90fd7c4f6a 100644 --- a/src/lib/ModernAutoControlledComponent.js +++ b/src/lib/ModernAutoControlledComponent.js @@ -24,10 +24,50 @@ * hoisted and exposed by the HOC. */ import _ from 'lodash' -import { Component } from 'react' -import { getAutoControlledStateValue, getDefaultPropName } from './AutoControlledComponent' +import React from 'react' -export default class ModernAutoControlledComponent extends Component { +const getDefaultPropName = (prop) => `default${prop[0].toUpperCase() + prop.slice(1)}` + +/** + * Return the auto controlled state value for a give prop. The initial value is chosen in this order: + * - regular props + * - then, default props + * - then, initial state + * - then, `checked` defaults to false + * - then, `value` defaults to '' or [] if props.multiple + * - else, undefined + * + * @param {string} propName A prop name + * @param {object} [props] A props object + * @param {object} [state] A state object + * @param {boolean} [includeDefaults=false] Whether or not to heed the default props or initial state + */ +const getAutoControlledStateValue = (propName, props, state, includeDefaults = false) => { + // regular props + const propValue = props[propName] + if (propValue !== undefined) return propValue + + if (includeDefaults) { + // defaultProps + const defaultProp = props[getDefaultPropName(propName)] + if (defaultProp !== undefined) return defaultProp + + // initial state - state may be null or undefined + if (state) { + const initialState = state[propName] + if (initialState !== undefined) return initialState + } + } + + // React doesn't allow changing from uncontrolled to controlled components, + // default checked/value if they were not present. + if (propName === 'checked') return false + if (propName === 'value') return props.multiple ? [] : '' + + // otherwise, undefined +} + +export default class ModernAutoControlledComponent extends React.Component { constructor(...args) { super(...args) @@ -147,10 +187,14 @@ export default class ModernAutoControlledComponent extends Component { // Due to the inheritance of the AutoControlledComponent we should call its // getAutoControlledStateFromProps() and merge it with the existing state if (getAutoControlledStateFromProps) { - const computedState = getAutoControlledStateFromProps(props, { - ...state, - ...newStateFromProps, - }) + const computedState = getAutoControlledStateFromProps( + props, + { + ...state, + ...newStateFromProps, + }, + state, + ) // We should follow the idea of getDerivedStateFromProps() and return only modified state return { ...newStateFromProps, ...computedState } diff --git a/src/lib/index.js b/src/lib/index.js index 20c79369ff..4c0e8bb44e 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,6 +1,5 @@ import makeDebugger from './makeDebugger' -export AutoControlledComponent from './AutoControlledComponent' export ModernAutoControlledComponent from './ModernAutoControlledComponent' export * as childrenUtils from './childrenUtils' diff --git a/src/modules/Dropdown/Dropdown.js b/src/modules/Dropdown/Dropdown.js index 5b460bb4eb..2a20b53c1d 100644 --- a/src/modules/Dropdown/Dropdown.js +++ b/src/modules/Dropdown/Dropdown.js @@ -8,7 +8,7 @@ import React, { Children, cloneElement, createRef } from 'react' import shallowEqual from 'shallowequal' import { - AutoControlledComponent as Component, + ModernAutoControlledComponent as Component, childrenUtils, customPropTypes, doesNodeContainClick, @@ -26,10 +26,14 @@ import DropdownItem from './DropdownItem' import DropdownHeader from './DropdownHeader' import DropdownMenu from './DropdownMenu' import DropdownSearchInput from './DropdownSearchInput' +import getMenuOptions from './utils/getMenuOptions' +import getSelectedIndex from './utils/getSelectedIndex' const debug = makeDebugger('dropdown') const getKeyOrValue = (key, value) => (_.isNil(key) ? value : key) +const getKeyAndValues = (options) => + options ? options.map((option) => _.pick(option, ['key', 'value'])) : options /** * A dropdown allows a user to select a value from a series of options. @@ -391,37 +395,82 @@ export default class Dropdown extends Component { return { focus: false, searchQuery: '' } } - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - debug('componentWillMount()') + static getAutoControlledStateFromProps(nextProps, computedState, prevState) { + // These values are stored only for a comparison on next getAutoControlledStateFromProps() + const derivedState = { __value: nextProps.value, __options: nextProps.options } + + if (!shallowEqual(nextProps.value, prevState.__value)) { + derivedState.selectedIndex = getSelectedIndex({ + additionLabel: nextProps.additionLabel, + additionPosition: nextProps.additionPosition, + allowAdditions: nextProps.allowAdditions, + deburr: nextProps.deburr, + multiple: nextProps.multiple, + search: nextProps.search, + selectedIndex: computedState.selectedIndex, + + value: computedState.value, + options: nextProps.options, + searchQuery: computedState.searchQuery, + }) + } + + // The selected index is only dependent on option keys/values. + // We only check those properties to avoid recursive performance impacts. + // https://github.com/Semantic-Org/Semantic-UI-React/issues/3000 + if (!_.isEqual(getKeyAndValues(nextProps.options), getKeyAndValues(prevState.__options))) { + derivedState.selectedIndex = getSelectedIndex({ + additionLabel: nextProps.additionLabel, + additionPosition: nextProps.additionPosition, + allowAdditions: nextProps.allowAdditions, + deburr: nextProps.deburr, + multiple: nextProps.multiple, + search: nextProps.search, + selectedIndex: computedState.selectedIndex, + + value: computedState.value, + options: nextProps.options, + searchQuery: computedState.searchQuery, + }) + } + + return derivedState + } + + componentDidMount() { + debug('componentDidMount()') const { open, value } = this.state - this.setValue(value) this.setSelectedIndex(value) if (open) { - this.open() + this.open(null, false) } } - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) { - super.UNSAFE_componentWillReceiveProps(nextProps) - debug('componentWillReceiveProps()') - debug('to props:', objectDiff(this.props, nextProps)) + shouldComponentUpdate(nextProps, nextState) { + return !shallowEqual(nextProps, this.props) || !shallowEqual(nextState, this.state) + } + + componentDidUpdate(prevProps, prevState) { + // eslint-disable-line complexity + debug('componentDidUpdate()') + debug('to state:', objectDiff(prevState, this.state)) + + const { closeOnBlur, minCharacters, openOnFocus, search } = this.props /* eslint-disable no-console */ if (process.env.NODE_ENV !== 'production') { // in development, validate value type matches dropdown type - const isNextValueArray = Array.isArray(nextProps.value) - const hasValue = _.has(nextProps, 'value') + const isNextValueArray = Array.isArray(this.props.value) + const hasValue = _.has(this.props, 'value') - if (hasValue && nextProps.multiple && !isNextValueArray) { + if (hasValue && this.props.multiple && !isNextValueArray) { console.error( 'Dropdown `value` must be an array when `multiple` is set.' + - ` Received type: \`${Object.prototype.toString.call(nextProps.value)}\`.`, + ` Received type: \`${Object.prototype.toString.call(this.props.value)}\`.`, ) - } else if (hasValue && !nextProps.multiple && isNextValueArray) { + } else if (hasValue && !this.props.multiple && isNextValueArray) { console.error( 'Dropdown `value` must not be an array when `multiple` is not set.' + ' Either set `multiple={true}` or use a string or number value.', @@ -430,32 +479,6 @@ export default class Dropdown extends Component { } /* eslint-enable no-console */ - if (!shallowEqual(nextProps.value, this.props.value)) { - debug('value changed, setting', nextProps.value) - this.setValue(nextProps.value) - this.setSelectedIndex(nextProps.value) - } - - // The selected index is only dependent on option keys/values. - // We only check those properties to avoid recursive performance impacts. - // https://github.com/Semantic-Org/Semantic-UI-React/issues/3000 - if ( - !_.isEqual(this.getKeyAndValues(nextProps.options), this.getKeyAndValues(this.props.options)) - ) { - this.setSelectedIndex(undefined, nextProps.options) - } - } - - shouldComponentUpdate(nextProps, nextState) { - return !shallowEqual(nextProps, this.props) || !shallowEqual(nextState, this.state) - } - - componentDidUpdate(prevProps, prevState) { - // eslint-disable-line complexity - debug('componentDidUpdate()') - debug('to state:', objectDiff(prevState, this.state)) - const { closeOnBlur, minCharacters, openOnFocus, search } = this.props - // focused / blurred if (!prevState.focus && this.state.focus) { debug('dropdown focused') @@ -565,7 +588,7 @@ export default class Dropdown extends Component { if (valueHasChanged) { // notify the onChange prop that the user is trying to change value - this.setValue(newValue) + this.setState({ value: newValue }) this.setSelectedIndex(newValue) this.handleChange(e, newValue) @@ -589,7 +612,20 @@ export default class Dropdown extends Component { if (!shouldSelect) return e.preventDefault() - const optionSize = _.size(this.getMenuOptions()) + const optionSize = _.size( + getMenuOptions({ + value: this.state.value, + options: this.props.options, + searchQuery: this.state.searchQuery, + + additionLabel: this.props.additionLabel, + additionPosition: this.props.additionPosition, + allowAdditions: this.props.allowAdditions, + deburr: this.props.deburr, + multiple: this.props.multiple, + search: this.props.search, + }), + ) if (search && optionSize === 0) return this.makeSelectedItemActive(e) @@ -611,7 +647,7 @@ export default class Dropdown extends Component { // remove most recent value const newValue = _.dropRight(value) - this.setValue(newValue) + this.setState({ value: newValue }) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } @@ -706,7 +742,7 @@ export default class Dropdown extends Component { // notify the onChange prop that the user is trying to change value if (valueHasChanged) { - this.setValue(newValue) + this.setState({ value: newValue }) this.setSelectedIndex(value) this.handleChange(e, newValue) @@ -772,7 +808,7 @@ export default class Dropdown extends Component { const newQuery = value _.invoke(this.props, 'onSearchChange', e, { ...this.props, searchQuery: newQuery }) - this.trySetState({ searchQuery: newQuery, selectedIndex: 0 }) + this.setState({ searchQuery: newQuery, selectedIndex: 0 }) // open search dropdown on search query if (!open && newQuery.length >= minCharacters) { @@ -787,100 +823,30 @@ export default class Dropdown extends Component { // Getters // ---------------------------------------- - getKeyAndValues = (options) => - options ? options.map((option) => _.pick(option, ['key', 'value'])) : options - - // There are times when we need to calculate the options based on a value - // that hasn't yet been persisted to state. - getMenuOptions = ( - value = this.state.value, - options = this.props.options, - searchQuery = this.state.searchQuery, - ) => { - const { additionLabel, additionPosition, allowAdditions, deburr, multiple, search } = this.props - - let filteredOptions = options - - // filter out active options - if (multiple) { - filteredOptions = _.filter(filteredOptions, (opt) => !_.includes(value, opt.value)) - } - - // filter by search query - if (search && searchQuery) { - if (_.isFunction(search)) { - filteredOptions = search(filteredOptions, searchQuery) - } else { - // remove diacritics on search input and options, if deburr prop is set - const strippedQuery = deburr ? _.deburr(searchQuery) : searchQuery - - const re = new RegExp(_.escapeRegExp(strippedQuery), 'i') - - filteredOptions = _.filter(filteredOptions, (opt) => - re.test(deburr ? _.deburr(opt.text) : opt.text), - ) - } - } - - // insert the "add" item - if ( - allowAdditions && - search && - searchQuery && - !_.some(filteredOptions, { text: searchQuery }) - ) { - const additionLabelElement = React.isValidElement(additionLabel) - ? React.cloneElement(additionLabel, { key: 'addition-label' }) - : additionLabel || '' - - const addItem = { - key: 'addition', - // by using an array, we can pass multiple elements, but when doing so - // we must specify a `key` for React to know which one is which - text: [additionLabelElement, {searchQuery}], - value: searchQuery, - className: 'addition', - 'data-additional': true, - } - if (additionPosition === 'top') filteredOptions.unshift(addItem) - else filteredOptions.push(addItem) - } - - return filteredOptions - } - getSelectedItem = () => { const { selectedIndex } = this.state - const options = this.getMenuOptions() + const options = getMenuOptions({ + value: this.state.value, + options: this.props.options, + searchQuery: this.state.searchQuery, + + additionLabel: this.props.additionLabel, + additionPosition: this.props.additionPosition, + allowAdditions: this.props.allowAdditions, + deburr: this.props.deburr, + multiple: this.props.multiple, + search: this.props.search, + }) return _.get(options, `[${selectedIndex}]`) } - getEnabledIndices = (givenOptions) => { - const options = givenOptions || this.getMenuOptions() - - return _.reduce( - options, - (memo, item, index) => { - if (!item.disabled) memo.push(index) - return memo - }, - [], - ) - } - getItemByValue = (value) => { const { options } = this.props return _.find(options, { value }) } - getMenuItemIndexByValue = (value, givenOptions) => { - const options = givenOptions || this.getMenuOptions() - - return _.findIndex(options, ['value', value]) - } - getDropdownAriaOptions = () => { const { loading, disabled, search, multiple } = this.props const { open } = this.state @@ -917,54 +883,29 @@ export default class Dropdown extends Component { const { searchQuery } = this.state if (searchQuery === undefined || searchQuery === '') return - this.trySetState({ searchQuery: '' }) + this.setState({ searchQuery: '' }) this.setSelectedIndex(value, undefined, '') } - setValue = (value) => { - debug('setValue()', value) - this.trySetState({ value }) - } - setSelectedIndex = ( value = this.state.value, optionsProps = this.props.options, searchQuery = this.state.searchQuery, ) => { - const { multiple } = this.props - const { selectedIndex } = this.state - const options = this.getMenuOptions(value, optionsProps, searchQuery) - const enabledIndicies = this.getEnabledIndices(options) - - let newSelectedIndex - - // update the selected index - if (!selectedIndex || selectedIndex < 0) { - const firstIndex = enabledIndicies[0] - - // Select the currently active item, if none, use the first item. - // Multiple selects remove active items from the list, - // their initial selected index should be 0. - newSelectedIndex = multiple - ? firstIndex - : this.getMenuItemIndexByValue(value, options) || enabledIndicies[0] - } else if (multiple) { - // multiple selects remove options from the menu as they are made active - // keep the selected index within range of the remaining items - if (selectedIndex >= options.length - 1) { - newSelectedIndex = enabledIndicies[enabledIndicies.length - 1] - } - } else { - const activeIndex = this.getMenuItemIndexByValue(value, options) - - // regular selects can only have one active item - // set the selected index to the currently active item - newSelectedIndex = _.includes(enabledIndicies, activeIndex) ? activeIndex : undefined - } - - if (!newSelectedIndex || newSelectedIndex < 0) { - newSelectedIndex = enabledIndicies[0] - } + const newSelectedIndex = getSelectedIndex({ + additionLabel: this.props.additionLabel, + additionPosition: this.props.additionPosition, + allowAdditions: this.props.allowAdditions, + deburr: this.props.deburr, + multiple: this.props.multiple, + search: this.props.search, + // eslint-disable-next-line react/no-access-state-in-setstate + selectedIndex: this.state.selectedIndex, + + value, + options: optionsProps, + searchQuery, + }) this.setState({ selectedIndex: newSelectedIndex }) } @@ -989,7 +930,7 @@ export default class Dropdown extends Component { debug('remove value:', labelProps.value) debug('new value:', newValue) - this.setValue(newValue) + this.setState({ value: newValue }) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } @@ -998,7 +939,18 @@ export default class Dropdown extends Component { debug('moveSelectionBy()') debug(`offset: ${offset}`) - const options = this.getMenuOptions() + const options = getMenuOptions({ + value: this.state.value, + options: this.props.options, + searchQuery: this.state.searchQuery, + + additionLabel: this.props.additionLabel, + additionPosition: this.props.additionPosition, + allowAdditions: this.props.allowAdditions, + deburr: this.props.deburr, + multiple: this.props.multiple, + search: this.props.search, + }) // Prevent infinite loop // TODO: remove left part of condition after children API will be removed @@ -1013,8 +965,11 @@ export default class Dropdown extends Component { // if 'wrapSelection' is set to false and selection is after last or before first, it just does not change if (!wrapSelection && (nextIndex > lastIndex || nextIndex < 0)) { nextIndex = startIndex - } else if (nextIndex > lastIndex) nextIndex = 0 - else if (nextIndex < 0) nextIndex = lastIndex + } else if (nextIndex > lastIndex) { + nextIndex = 0 + } else if (nextIndex < 0) { + nextIndex = lastIndex + } if (options[nextIndex].disabled) { this.moveSelectionBy(offset, nextIndex) @@ -1050,7 +1005,7 @@ export default class Dropdown extends Component { const { multiple } = this.props const newValue = multiple ? [] : '' - this.setValue(newValue) + this.setState({ value: newValue }) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } @@ -1141,30 +1096,31 @@ export default class Dropdown extends Component { // set state only if there's a relevant difference if (!upward !== !this.state.upward) { - this.trySetState({ upward }) + this.setState({ upward }) } } - open = (e) => { - const { disabled, open, search } = this.props - debug('open()', { disabled, open, search }) + open = (e = null, triggerSetState = true) => { + const { disabled, search } = this.props + debug('open()', { disabled, search, open: this.state.open }) if (disabled) return if (search) _.invoke(this.searchRef.current, 'focus') _.invoke(this.props, 'onOpen', e, this.props) - this.trySetState({ open: true }) + if (triggerSetState) { + this.setState({ open: true }) + } this.scrollSelectedItemIntoView() } close = (e, callback = this.handleClose) => { - const { open } = this.state - debug('close()', { open }) + debug('close()', { open: this.state.open }) - if (open) { + if (this.state.open) { _.invoke(this.props, 'onClose', e, this.props) - this.trySetState({ open: false }, callback) + this.setState({ open: false }, callback) } } @@ -1279,7 +1235,18 @@ export default class Dropdown extends Component { // lazy load, only render options when open if (lazyLoad && !open) return null - const options = this.getMenuOptions() + const options = getMenuOptions({ + value: this.state.value, + options: this.props.options, + searchQuery: this.state.searchQuery, + + additionLabel: this.props.additionLabel, + additionPosition: this.props.additionPosition, + allowAdditions: this.props.allowAdditions, + deburr: this.props.deburr, + multiple: this.props.multiple, + search: this.props.search, + }) if (noResultsMessage !== null && search && _.isEmpty(options)) { return
{noResultsMessage}
diff --git a/src/modules/Dropdown/utils/getMenuOptions.js b/src/modules/Dropdown/utils/getMenuOptions.js new file mode 100644 index 0000000000..1b3751df3b --- /dev/null +++ b/src/modules/Dropdown/utils/getMenuOptions.js @@ -0,0 +1,62 @@ +import _ from 'lodash' +import React from 'react' + +// There are times when we need to calculate the options based on a value +// that hasn't yet been persisted to state. +export default function getMenuOptions(config) { + const { + additionLabel, + additionPosition, + allowAdditions, + deburr, + multiple, + options, + search, + searchQuery, + value, + } = config + + let filteredOptions = options + + // filter out active options + if (multiple) { + filteredOptions = _.filter(filteredOptions, (opt) => !_.includes(value, opt.value)) + } + + // filter by search query + if (search && searchQuery) { + if (_.isFunction(search)) { + filteredOptions = search(filteredOptions, searchQuery) + } else { + // remove diacritics on search input and options, if deburr prop is set + const strippedQuery = deburr ? _.deburr(searchQuery) : searchQuery + + const re = new RegExp(_.escapeRegExp(strippedQuery), 'i') + + filteredOptions = _.filter(filteredOptions, (opt) => + re.test(deburr ? _.deburr(opt.text) : opt.text), + ) + } + } + + // insert the "add" item + if (allowAdditions && search && searchQuery && !_.some(filteredOptions, { text: searchQuery })) { + const additionLabelElement = React.isValidElement(additionLabel) + ? React.cloneElement(additionLabel, { key: 'addition-label' }) + : additionLabel || '' + + const addItem = { + key: 'addition', + // by using an array, we can pass multiple elements, but when doing so + // we must specify a `key` for React to know which one is which + text: [additionLabelElement, {searchQuery}], + value: searchQuery, + className: 'addition', + 'data-additional': true, + } + if (additionPosition === 'top') filteredOptions.unshift(addItem) + else filteredOptions.push(addItem) + } + + return filteredOptions +} diff --git a/src/modules/Dropdown/utils/getSelectedIndex.js b/src/modules/Dropdown/utils/getSelectedIndex.js new file mode 100644 index 0000000000..90cdfbf1e7 --- /dev/null +++ b/src/modules/Dropdown/utils/getSelectedIndex.js @@ -0,0 +1,70 @@ +import _ from 'lodash' +import getMenuOptions from './getMenuOptions' + +export default function getSelectedIndex(config) { + const { + additionLabel, + additionPosition, + allowAdditions, + deburr, + multiple, + options, + search, + searchQuery, + selectedIndex, + value, + } = config + + const menuOptions = getMenuOptions({ + value, + options, + searchQuery, + + additionLabel, + additionPosition, + allowAdditions, + deburr, + multiple, + search, + }) + const enabledIndicies = _.reduce( + menuOptions, + (memo, item, index) => { + if (!item.disabled) memo.push(index) + return memo + }, + [], + ) + + let newSelectedIndex + + // update the selected index + if (!selectedIndex || selectedIndex < 0) { + const firstIndex = enabledIndicies[0] + + // Select the currently active item, if none, use the first item. + // Multiple selects remove active items from the list, + // their initial selected index should be 0. + newSelectedIndex = multiple + ? firstIndex + : _.findIndex(menuOptions, ['value', value]) || enabledIndicies[0] + } else if (multiple) { + // multiple selects remove options from the menu as they are made active + // keep the selected index within range of the remaining items + if (selectedIndex >= menuOptions.length - 1) { + newSelectedIndex = enabledIndicies[enabledIndicies.length - 1] + } + } else { + const activeIndex = _.findIndex(menuOptions, ['value', value]) + + // regular selects can only have one active item + // set the selected index to the currently active item + newSelectedIndex = _.includes(enabledIndicies, activeIndex) ? activeIndex : undefined + } + + if (!newSelectedIndex || newSelectedIndex < 0) { + newSelectedIndex = enabledIndicies[0] + } + + return newSelectedIndex +} diff --git a/test/specs/lib/AutoControlledComponent-test.js b/test/specs/lib/AutoControlledComponent-test.js deleted file mode 100644 index 32543b9d5c..0000000000 --- a/test/specs/lib/AutoControlledComponent-test.js +++ /dev/null @@ -1,342 +0,0 @@ -/* eslint-disable no-console */ -import faker from 'faker' -import _ from 'lodash' -import React from 'react' - -import { AutoControlledComponent } from 'src/lib' -import { consoleUtil } from 'test/utils' - -let TestClass - -/* eslint-disable */ -const createTestClass = (options = {}) => - class Test extends AutoControlledComponent { - static autoControlledProps = options.autoControlledProps - static defaultProps = options.defaultProps - getInitialAutoControlledState() { - return options.state - } - render = () =>
- } -/* eslint-enable */ - -const toDefaultName = (prop) => `default${prop.slice(0, 1).toUpperCase() + prop.slice(1)}` - -const makeProps = () => ({ - computer: 'hardware', - flux: 'capacitor', - ion: 'belt', -}) - -const makeDefaultProps = (props) => - _.transform(props, (res, val, key) => { - res[toDefaultName(key)] = val - }) - -describe('extending AutoControlledComponent', () => { - beforeEach(() => { - TestClass = createTestClass({ autoControlledProps: [], state: {} }) - }) - - it('does not throw with a `null` state', () => { - TestClass = createTestClass({ autoControlledProps: [], state: null }) - shallow() - }) - - describe('trySetState', () => { - it('is an instance method', () => { - shallow() - .instance() - .trySetState.should.be.a('function') - }) - - it('sets state for autoControlledProps', () => { - consoleUtil.disableOnce() - - const autoControlledProps = _.keys(makeProps()) - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.verb() - - TestClass = createTestClass({ autoControlledProps }) - const wrapper = shallow() - - wrapper.instance().trySetState({ [randomProp]: randomValue }) - - wrapper.should.have.state(randomProp, randomValue) - }) - - it('does not set state for props defined by the parent', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.phrase() - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.instance().trySetState({ [randomProp]: randomValue }) - - // not updated - wrapper.should.not.have.state(randomProp, randomValue) - - // is original value - wrapper.should.have.state(randomProp, props[randomProp]) - }) - - it('sets state for props passed as undefined by the parent', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.phrase() - - props[randomProp] = undefined - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.instance().trySetState({ [randomProp]: randomValue }) - - wrapper.should.have.state(randomProp, randomValue) - }) - - it('does not set state for props passed as null by the parent', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.phrase() - - props[randomProp] = null - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.instance().trySetState({ [randomProp]: randomValue }) - - // not updated - wrapper.should.not.have.state(randomProp, randomValue) - - // is original value - wrapper.should.have.state(randomProp, props[randomProp]) - }) - }) - - describe('initial state', () => { - it('is derived from autoControlledProps in props', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - TestClass = createTestClass({ autoControlledProps, state: {} }) - shallow() - .state() - .should.deep.equal(props) - }) - - it('does not include non autoControlledProps', () => { - const props = makeProps() - const wrapper = shallow() - - _.each(props, (val, key) => wrapper.should.not.have.state(key, val)) - }) - - it('includes non autoControlled state', () => { - const props = makeProps() - - TestClass = createTestClass({ autoControlledProps: [], state: { foo: 'bar' } }) - shallow().should.have.state('foo', 'bar') - }) - - it('uses the initial state if default and regular props are undefined', () => { - consoleUtil.disableOnce() - - const defaultProps = { defaultFoo: undefined } - const autoControlledProps = ['foo'] - - TestClass = createTestClass({ autoControlledProps, defaultProps, state: { foo: 'bar' } }) - - shallow().should.have.state('foo', 'bar') - }) - - it('uses the default prop if the regular prop is undefined', () => { - consoleUtil.disableOnce() - - const defaultProps = { defaultFoo: 'default' } - const autoControlledProps = ['foo'] - - TestClass = createTestClass({ autoControlledProps, defaultProps, state: {} }) - - shallow().should.have.state('foo', 'default') - }) - - it('uses the regular prop when a default is also defined', () => { - consoleUtil.disableOnce() - - const defaultProps = { defaultFoo: 'default' } - const autoControlledProps = ['foo'] - - TestClass = createTestClass({ autoControlledProps, defaultProps, state: {} }) - - shallow().should.have.state('foo', 'initial') - }) - - it('defaults "checked" to false if not present', () => { - consoleUtil.disableOnce() - TestClass.autoControlledProps.push('checked') - - shallow().should.have.state('checked', false) - }) - - it('defaults "value" to an empty string if not present', () => { - consoleUtil.disableOnce() - TestClass.autoControlledProps.push('value') - - shallow().should.have.state('value', '') - }) - - it('defaults "value" to an empty array if "multiple"', () => { - consoleUtil.disableOnce() - TestClass.autoControlledProps.push('value') - - shallow() - .state() - .should.deep.equal({ value: [] }) - }) - }) - - describe('default props', () => { - it('are applied to state for props in autoControlledProps', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - const defaultProps = makeDefaultProps(props) - - TestClass = createTestClass({ autoControlledProps, state: {} }) - shallow() - .state() - .should.deep.equal(props) - }) - - it('are not applied to state for normal props', () => { - const props = makeProps() - const defaultProps = makeDefaultProps(props) - - const wrapper = shallow() - - _.each(props, (val, key) => wrapper.should.not.have.state(key, val)) - }) - - it('allows trySetState to work on non-default autoControlledProps', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - const defaultProps = makeDefaultProps(props) - - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.phrase() - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.instance().trySetState({ [randomProp]: randomValue }) - - wrapper.should.have.state(randomProp, randomValue) - }) - }) - - describe('changing props', () => { - it('sets state for props in autoControlledProps', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - const randomProp = _.sample(autoControlledProps) - const randomValue = faker.hacker.phrase() - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.setProps({ [randomProp]: randomValue }) - - wrapper.should.have.state(randomProp, randomValue) - }) - - it('does not set state for props not in autoControlledProps', () => { - consoleUtil.disableOnce() - const props = makeProps() - - const randomProp = _.sample(_.keys(props)) - const randomValue = faker.hacker.phrase() - - TestClass = createTestClass({ autoControlledProps: [], state: {} }) - const wrapper = shallow() - - wrapper.setProps({ [randomProp]: randomValue }) - - wrapper.should.not.have.state(randomProp, randomValue) - }) - - it('does not set state for default props when changed', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - const defaultProps = makeDefaultProps(props) - - const randomDefaultProp = _.sample(defaultProps) - const randomValue = faker.hacker.phrase() - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.setProps({ [randomDefaultProp]: randomValue }) - - wrapper.should.not.have.state(randomDefaultProp, randomValue) - }) - - it('does not return state to default props when setting props undefined', () => { - consoleUtil.disableOnce() - - const autoControlledProps = ['foo'] - const defaultProps = { defaultFoo: 'default' } - - TestClass = createTestClass({ autoControlledProps, defaultProps, state: {} }) - const wrapper = shallow() - - // default value - wrapper.should.have.state('foo', 'initial') - - wrapper.setProps({ foo: undefined }) - - wrapper.should.have.state('foo', 'initial') - }) - - it('does not set state for props passed as null by the parent', () => { - consoleUtil.disableOnce() - - const props = makeProps() - const autoControlledProps = _.keys(props) - - const randomProp = _.sample(autoControlledProps) - - TestClass = createTestClass({ autoControlledProps, state: {} }) - const wrapper = shallow() - - wrapper.setProps({ [randomProp]: null }) - - wrapper.should.have.state(randomProp, null) - }) - }) -}) diff --git a/test/specs/modules/Dropdown/Dropdown-test.js b/test/specs/modules/Dropdown/Dropdown-test.js index 58e4d4d2ea..d2b8d631e3 100644 --- a/test/specs/modules/Dropdown/Dropdown-test.js +++ b/test/specs/modules/Dropdown/Dropdown-test.js @@ -627,23 +627,23 @@ describe('Dropdown', () => { it('will call setSelectedIndex if options change', () => { wrapperMount() - const instance = wrapper.instance() - sandbox.spy(instance, 'setSelectedIndex') + wrapper.simulate('click') + domEvent.keyDown(document, { key: 'ArrowDown' }) + wrapper.should.have.state('selectedIndex', 1) wrapper.setProps({ options: [] }) - - instance.setSelectedIndex.should.have.been.calledOnce() + wrapper.should.have.not.state('selectedIndex') }) it('will not call setSelectedIndex if options have not changed', () => { wrapperMount() - const instance = wrapper.instance() - sandbox.spy(instance, 'setSelectedIndex') + wrapper.simulate('click') + domEvent.keyDown(document, { key: 'ArrowDown' }) + wrapper.should.have.state('selectedIndex', 1) wrapper.setProps({ options }) - - instance.setSelectedIndex.should.not.have.been.calledOnce() + wrapper.should.have.state('selectedIndex', 1) }) }) @@ -750,7 +750,7 @@ describe('Dropdown', () => { const randomIndex = 1 + _.random(options.length - 2) const value = options[randomIndex].value - wrapperShallow() + wrapperMount() wrapper.setProps({ options, value })