From e69d6f822ce8b7b6ac1e821ba7de874977ded423 Mon Sep 17 00:00:00 2001 From: flannanl Date: Wed, 1 May 2019 10:34:08 -0400 Subject: [PATCH] feat(filters): add code to support multi level items * feat(filters): refactor code to support nested level * feat(filters): add code to support multi level items * fix(filters): styles * fix(filters): fix sorting and remove unneeded fields from model * fix(filters): support flat list inputs * fix(filters): fix lint * fix(filters): fix switching flat and hierarchy in story --- .storybook/addons.js | 1 + package.json | 3 +- .../MultiSelect/MultiSelect-story.js | 262 +- .../NestedFilterableMultiselect.js | 681 +++-- .../NestedFilterableMultiselect-test.js | 1129 ++++++- .../NestedFilterableMultiselect-test.js.snap | 2664 +++++++++++++++++ .../tools/__tests__/sorting-test.js | 173 +- src/components/MultiSelect/tools/filter.js | 69 +- src/components/MultiSelect/tools/sorting.js | 106 +- src/internal/Selection.js | 53 +- 10 files changed, 4677 insertions(+), 464 deletions(-) diff --git a/.storybook/addons.js b/.storybook/addons.js index 4b873c2..8ebeb8a 100644 --- a/.storybook/addons.js +++ b/.storybook/addons.js @@ -1,3 +1,4 @@ import '@storybook/addon-actions/register'; +import '@storybook/addon-knobs/register'; import '@storybook/addon-links/register'; import 'storybook-addon-a11y/register'; diff --git a/package.json b/package.json index 7d16a06..8b7df4e 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,8 @@ "jest": { "collectCoverageFrom": [ "src/components/**/*.js", - "!src/components/**/*-story.js" + "!src/components/**/*-story.js", + "src/internal/**/*.js" ], "setupFiles": [ "/config/polyfills.js", diff --git a/src/components/MultiSelect/MultiSelect-story.js b/src/components/MultiSelect/MultiSelect-story.js index c91c724..8c48871 100644 --- a/src/components/MultiSelect/MultiSelect-story.js +++ b/src/components/MultiSelect/MultiSelect-story.js @@ -24,6 +24,16 @@ const items = [ { id: 'opt-4', text: 'Option 4', + options: [ + { + id: 'subopt-25', + text: 'SubOption 25', + }, + { + id: 'subopt-30', + text: 'SubOption 30', + }, + ], }, ], }, @@ -50,10 +60,49 @@ const items = [ { id: 'opt-7', text: 'Option 7', + options: [ + { + id: 'subopt-20', + text: 'SubOption 20', + }, + { + id: 'subopt-15', + text: 'SubOption 15', + }, + { + id: 'subopt-18', + text: 'SubOption 18', + }, + ], }, { - id: 'opt-7', - text: 'Option 7', + id: 'opt-8', + text: 'Option 8', + options: [ + { + id: 'subopt-5', + text: 'SubOption 5', + }, + { + id: 'subopt-10', + text: 'SubOption 10', + }, + ], + }, + ], + }, + { + id: 'item-5', + text: 'Item 5', + category: 'America', + options: [ + { + id: 'opt-9', + text: 'Option 9', + }, + { + id: 'opt-10', + text: 'Option 10', }, ], }, @@ -72,6 +121,17 @@ const selectedItems = [ { id: 'opt-4', text: 'Option 4', + options: [ + { + id: 'subopt-25', + text: 'SubOption 25', + }, + { + id: 'subopt-30', + text: 'SubOption 30', + checked: true, + }, + ], }, ], }, @@ -99,16 +159,187 @@ const selectedItems = [ { id: 'opt-7', text: 'Option 7', + checked: true, + options: [ + { + id: 'subopt-20', + text: 'SubOption 20', + }, + { + id: 'subopt-15', + text: 'SubOption 15', + }, + { + id: 'subopt-18', + text: 'SubOption 18', + }, + ], }, { id: 'opt-8', text: 'Option 8', - checked: true, + options: [ + { + id: 'subopt-5', + text: 'SubOption 5', + }, + { + id: 'subopt-10', + text: 'SubOption 10', + }, + ], }, ], }, ]; -const defaultLabel = 'MultiSelect Label'; + +const flattenItems = [ + { + id: 'item-1', + text: 'Item 1', + category: 'Europe', + level: 0, + }, + { + id: 'item-2', + text: 'Item 2', + category: 'Europe', + level: 0, + hasChildren: true, + }, + { + id: 'opt-3', + text: 'Option 3', + category: 'Europe', + level: 1, + parentId: 'item-2', + }, + { + id: 'opt-4', + text: 'Option 4', + category: 'Europe', + level: 1, + hasChildren: true, + parentId: 'item-2', + }, + { + id: 'subopt-25', + text: 'SubOption 25', + category: 'Europe', + level: 2, + parentId: 'opt-4', + }, + { + id: 'subopt-30', + text: 'SubOption 30', + category: 'Europe', + level: 2, + parentId: 'opt-4', + }, + { + id: 'item-3', + text: 'Item 3', + category: 'Asia', + level: 0, + hasChildren: true, + }, + { + id: 'opt-5', + text: 'Option 5', + category: 'Asia', + level: 1, + parentId: 'item-3', + }, + { + id: 'item-4', + text: 'Item 4', + category: 'America', + level: 0, + hasChildren: true, + }, + { + id: 'opt-7', + text: 'Option 7', + category: 'America', + level: 1, + hasChildren: true, + parentId: 'item-4', + }, + { + id: 'subopt-20', + text: 'SubOption 20', + category: 'America', + level: 2, + parentId: 'opt-7', + }, + { + id: 'subopt-15', + text: 'SubOption 15', + category: 'America', + level: 2, + parentId: 'opt-7', + }, + { + id: 'opt-8', + text: 'Option 8', + category: 'America', + level: 1, + parentId: 'item-4', + }, +]; +const flattenSelectedItems = [ + { + id: 'item-2', + text: 'Item 2', + category: 'Europe', + level: 0, + hasChildren: true, + }, + { + id: 'opt-3', + text: 'Option 3', + category: 'Europe', + level: 1, + parentId: 'item-2', + }, + { + id: 'subopt-30', + text: 'SubOption 30', + category: 'Europe', + level: 2, + parentId: 'opt-4', + }, + { + id: 'item-3', + text: 'Item 3', + category: 'Asia', + level: 0, + hasChildren: true, + }, + { + id: 'opt-5', + text: 'Option 5', + category: 'Asia', + level: 1, + parentId: 'item-3', + }, + { + id: 'item-4', + text: 'Item 4', + category: 'America', + level: 0, + hasChildren: true, + }, + { + id: 'opt-7', + text: 'Option 7', + category: 'America', + level: 1, + hasChildren: true, + parentId: 'item-4', + }, +]; + const defaultPlaceholder = 'Filter'; const types = { @@ -117,20 +348,9 @@ const types = { }; const props = () => ({ - filterable: boolean( - 'Filterable (`` instead of ``)', - false - ), + flatList: boolean('Flat list', false), disabled: boolean('Disabled (disabled)', false), light: boolean('Light variant (light)', false), - useTitleInItem: boolean('Show tooltip on hover', false), - type: select('UI type (Only for ``) (type)', types, 'default'), - label: text('Label (label)', defaultLabel), - invalid: boolean('Show form validation UI (invalid)', false), - invalidText: text( - 'Form validation UI content (invalidText)', - 'Invalid Selection' - ), onChange: action('onChange'), }); @@ -143,15 +363,17 @@ storiesOf('NestedFilterableMultiselect', module) Nested Filterable Multiselect `, })(() => { - const { filterable, ...multiSelectProps } = props(); - const placeholder = !filterable ? undefined : defaultPlaceholder; + const { flatList, ...multiSelectProps } = props(); return (
(item ? item.text : '')} - initialSelectedItems={selectedItems} + initialSelectedItems={ + flatList ? flattenSelectedItems : selectedItems + } placeholder={defaultPlaceholder} />
diff --git a/src/components/MultiSelect/NestedFilterableMultiselect.js b/src/components/MultiSelect/NestedFilterableMultiselect.js index 6f99d37..71184a2 100644 --- a/src/components/MultiSelect/NestedFilterableMultiselect.js +++ b/src/components/MultiSelect/NestedFilterableMultiselect.js @@ -11,8 +11,13 @@ import Selection from '../../internal/Selection'; import { sortingPropTypes } from './MultiSelectPropTypes'; import { defaultItemToString } from './tools/itemToString'; import { groupedByCategory } from './tools/groupedByCategory'; -import { defaultSortItems, defaultCompareItems } from './tools/sorting'; -import { defaultFilterItems } from './tools/filter'; +import { + buildHierarchy, + defaultSortItems, + defaultCompareItems, + findParent, +} from './tools/sorting'; +import { defaultFilterItems, getAllChildren } from './tools/filter'; export default class NestedFilterableMultiselect extends React.Component { static propTypes = { @@ -88,44 +93,244 @@ export default class NestedFilterableMultiselect extends React.Component { showTooltip: true, }; + static find(items = [], target) { + let found; + items.some(item => { + if (item.id === target.id) { + found = item; + return true; + } + return false; + }); + return found; + } + + static computeId({ item, itemToString = defaultItemToString, parentId }) { + return `${parentId ? `${parentId}-` : ''}${item.id || itemToString(item)}`; + } + + static flatten({ + category, + items = [], + level, + parentId, + itemToString = defaultItemToString, + }) { + return items.reduce((list, item) => { + const mappedItem = { + ...item, + id: NestedFilterableMultiselect.computeId({ + item, + itemToString, + parentId, + }), + category: category || item.category, + level: level || 0, + hasChildren: !!item.options, + parentId, + }; + list.push(mappedItem); + if (Array.isArray(item.options) && item.options.length > 0) { + list.push( + ...NestedFilterableMultiselect.flatten({ + category: mappedItem.category, + items: item.options, + level: mappedItem.level + 1, + parentId: mappedItem.id, + itemToString, + }) + ); + } + return list; + }, []); + } + + static cleanItem(item) { + const result = { ...item }; + delete result.options; + delete result.checked; + return result; + } + + static getDerivedStateFromProps(nextProps, currentState) { + const { items, initialSelectedItems, itemToString } = nextProps; + const { flattenedItems, flattenedSelectedItems } = currentState; + + const itemsToProcess = initialSelectedItems + ? items.map(obj => initialSelectedItems.find(o => o.id === obj.id) || obj) + : items; + const isHierarchical = items.some(item => !!item.options); + const updatedItems = isHierarchical + ? NestedFilterableMultiselect.flatten({ + items: itemsToProcess, + itemToString, + }).map(NestedFilterableMultiselect.cleanItem) + : itemsToProcess; + + if (!isEqual(updatedItems, flattenedItems)) { + const updatedSelectedItems = isHierarchical + ? NestedFilterableMultiselect.flatten({ + items: initialSelectedItems, + itemToString, + }) + .filter((item, index, itemArray) => { + if (!item.parentId || item.checked) { + return true; + } + + // Any parent checked will make all its children checked + const hierarchy = buildHierarchy(item, itemArray); + const parentChecked = hierarchy.some(parent => parent.checked); + if (parentChecked) { + return true; + } + + // Any child checked will make its parent checked + const allChildren = getAllChildren(item, itemArray); + const childChecked = allChildren.some(child => child.checked); + if (childChecked) { + return true; + } + + // If none of the children has the `checked` flag, + // all children are considered checked. + const rootAllChildren = getAllChildren(hierarchy[0], itemArray); + return ( + rootAllChildren.length > 0 && + !rootAllChildren.some(child => child.checked) + ); + }) + .map(NestedFilterableMultiselect.cleanItem) + : initialSelectedItems.reduce( + (list, item) => { + // Any parent checked will make all its children checked + const hierarchy = buildHierarchy(item, updatedItems); + hierarchy.forEach(parent => { + if (!NestedFilterableMultiselect.find(list, parent)) { + list.push({ ...parent }); + } + }); + + // If none of the children has the `checked` flag, + // all children are considered checked. + const allChildren = getAllChildren(item, updatedItems); + if ( + !allChildren.some(child => + NestedFilterableMultiselect.find(list, child) + ) + ) { + list.push(...allChildren.map(child => ({ ...child }))); + } + + return list; + }, + [...initialSelectedItems] + ); + + flattenedItems.splice(0, flattenedItems.length, ...updatedItems); + flattenedSelectedItems.splice( + 0, + flattenedSelectedItems.length, + ...updatedSelectedItems + ); + + return { + ...currentState, + flattenedItems, + flattenedSelectedItems, + }; + } + + return null; + } + + static updateCheckedState({ + options = [], + itemToString = defaultItemToString, + parentId, + selectedItems, + }) { + return options.map(option => { + const optionId = NestedFilterableMultiselect.computeId({ + item: option, + itemToString, + parentId, + }); + const result = { ...option }; + if (result.options) { + result.options = NestedFilterableMultiselect.updateCheckedState({ + options: result.options, + itemToString, + parentId: optionId, + selectedItems, + }); + // The parent is checked only if all its children is checked + result.checked = !result.options.some(option => !option.checked); + } else { + result.checked = selectedItems.some( + selectedItem => selectedItem.id === optionId + ); + } + return result; + }); + } + constructor(props) { super(props); this.state = { highlightedIndex: null, isOpen: false, inputValue: '', - openSections: [], - checkedSuboptions: [], + flattenedItems: [], + flattenedSelectedItems: [], + expandedItems: [], }; } handleOnChange = changes => { - if (this.props.onChange) { - this.props.onChange(changes); - } - }; + const { items, itemToString, onChange } = this.props; + + if (onChange) { + const { selectedItems = [] } = changes; + + const isHierarchical = items.some(item => !!item.options); + const mappedSelectedItems = isHierarchical + ? items.reduce((list, item) => { + if (NestedFilterableMultiselect.find(selectedItems, item)) { + const selectedItem = { ...item }; + if (item.options) { + selectedItem.options = NestedFilterableMultiselect.updateCheckedState( + { + options: item.options, + itemToString, + parentId: NestedFilterableMultiselect.computeId({ + item, + itemToString, + }), + selectedItems, + } + ); + } + list.push(selectedItem); + } + return list; + }, []) + : selectedItems; - handleOnChangeSubOption = option => { - if (!option.checked) { - this.setState(prevState => ({ - checkedSuboptions: [...prevState.checkedSuboptions, option], - })); - } else { - this.setState(prevState => ({ - checkedSuboptions: prevState.checkedSuboptions.filter( - selectedOption => selectedOption !== option - ), - })); + onChange({ selectedItems: mappedSelectedItems }); } - option.checked = !option.checked; }; onToggle = item => { - !this.state.openSections.includes(item) - ? this.setState({ openSections: [...this.state.openSections, item] }) + const isExpanded = NestedFilterableMultiselect.find( + this.state.expandedItems, + item + ); + !isExpanded + ? this.setState({ expandedItems: [...this.state.expandedItems, item] }) : this.setState(prevState => ({ - openSections: prevState.openSections.filter( - itemOnState => itemOnState !== item + expandedItems: prevState.expandedItems.filter( + expandedItem => expandedItem.id !== item.id ), })); }; @@ -181,44 +386,45 @@ export default class NestedFilterableMultiselect extends React.Component { handleOnInputValueChange = debounce((value, { type }) => { if (type === Downshift.stateChangeTypes.changeInput) { + const { filterItems, itemToString } = this.props; const { - items, - initialSelectedItems, - filterItems, - itemToString, - } = this.props; - const { openSections, inputValue: prevInputValue } = this.state; + expandedItems, + flattenedItems: items, + inputValue: prevInputValue, + } = this.state; const inputValue = Array.isArray(value) ? prevInputValue : value; - const itemsToProcess = initialSelectedItems - ? items.map( - obj => initialSelectedItems.find(o => o.id === obj.id) || obj - ) - : items; - const matchedItems = itemsToProcess.filter(item => { - if (!item.options || openSections.includes(item) || !inputValue) { - return false; - } - const filteredItems = filterItems(item.options, { - itemToString, - inputValue, - }); - return filteredItems.length > 0; - }); - const itemsToExpand = - matchedItems.length > 0 - ? [...openSections, ...matchedItems] - : openSections; + const itemsToExpand = items.reduce((toExpand, item) => { + const allChildren = getAllChildren(item, items); + if (allChildren.length > 0) { + const filteredChildren = filterItems(allChildren, { + itemToString, + inputValue, + }); + if (filteredChildren.length > 0) { + if ( + !inputValue || + NestedFilterableMultiselect.find(expandedItems, item) + ) { + return toExpand; + } + if (!NestedFilterableMultiselect.find(toExpand, item)) { + toExpand.push(item); + } + } + } + return toExpand; + }, []); this.setState(() => { return { - openSections: itemsToExpand, + expandedItems: [...expandedItems, ...itemsToExpand], inputValue: inputValue || '', }; }); } - }, 200); + }, 200).bind(this); clearInputValue = event => { event.stopPropagation(); @@ -227,69 +433,81 @@ export default class NestedFilterableMultiselect extends React.Component { }; getParentItem = item => { - const { items } = this.props; - - let parent; - items.some(thisItem => { - if (thisItem.options && thisItem.options.includes(item)) { - parent = thisItem; - return true; - } - return false; - }); - - return parent; - }; - - handleSelectSubOptions = supOptions => { - supOptions.map(option => { - this.handleOnChangeSubOption(option); - }); + const { flattenedItems: items } = this.state; + return findParent(item, items); }; onItemChange = (item, selectedItems, onItemChange) => { - const parent = this.getParentItem(item); - - const options = parent ? parent.options : item.options; - const myCheckedOptions = options - ? options.filter(subOption => subOption.checked) - : null; - const myUncheckedOptions = options - ? options.filter(subOption => !subOption.checked) - : null; - - if (parent) { - this.handleOnChangeSubOption(item); - - const onlySupOpChecked = - myCheckedOptions.length == 1 && myCheckedOptions.includes(item); - if (onlySupOpChecked || myCheckedOptions.length == 0) { - onItemChange(parent); - } else { - this.handleOnChange({ selectedItems }); - } - } else { - onItemChange(item); - if (item.options) { - const includesItem = selectedItems.includes(item); - if (myCheckedOptions.length == 0 && !includesItem) { - this.handleSelectSubOptions(myUncheckedOptions); - } else { - this.handleSelectSubOptions(myCheckedOptions); + const { flattenedItems: items } = this.state; + + const toRemove = !!NestedFilterableMultiselect.find(selectedItems, item); + + const itemsChanged = [item]; + + if (item.parentId) { + // Walk parents + const parents = buildHierarchy(item, items).reverse(); + parents.shift(); + parents.some(parent => { + const isSelected = !!NestedFilterableMultiselect.find( + selectedItems, + parent + ); + const children = selectedItems.filter( + selectedItem => selectedItem.parentId === parent.id + ); + if (children.length === 1 && toRemove && isSelected) { + // Uncheck parent too and keep walking up + itemsChanged.push(parent); + return false; + } else if (!toRemove && !isSelected) { + // Check parent too + itemsChanged.push(parent); + return false; } - } + // If selecting a new item, we need to keep going up to + // make sure all parents are checked. + // If unselecting an item, we will break out when the + // current parent does not need to be removed + return toRemove; + }); } + + // Walk children + const children = getAllChildren(item, items); + if (children.length > 0) { + children.forEach(child => { + const isSelected = !!NestedFilterableMultiselect.find( + selectedItems, + child + ); + if (toRemove && isSelected) { + // Uncheck the child too + itemsChanged.push(child); + } else if (!toRemove && !isSelected) { + // Check the child too + itemsChanged.push(child); + } + }); + } + + onItemChange(itemsChanged); }; render() { - const { highlightedIndex, isOpen, inputValue, openSections } = this.state; + const { + highlightedIndex, + isOpen, + inputValue, + expandedItems, + flattenedItems: items, + flattenedSelectedItems: initialSelectedItems, + } = this.state; const { className: containerClassName, disabled, filterItems, - items, itemToString, - initialSelectedItems, id, locale, placeholder, @@ -300,9 +518,6 @@ export default class NestedFilterableMultiselect extends React.Component { showTooltip, } = this.props; - const itemsToProcess = initialSelectedItems - ? items.map(obj => initialSelectedItems.find(o => o.id === obj.id) || obj) - : items; const className = cx( 'bx--multi-select', 'bx--combo-box', @@ -348,28 +563,10 @@ export default class NestedFilterableMultiselect extends React.Component { {selectedItem.length > 0 && ( { - { - selectedItems.forEach(item => { - if (item.options) { - const myCheckedOptions = item.options.filter( - subOption => subOption.checked == true - ); - this.handleSelectSubOptions(myCheckedOptions); - } - }); - clearSelection(e); - } - }} - selectionCount={selectedItems.reduce((total, item) => { - if (item.options) { - return ( - total + - item.options.filter(option => option.checked).length - ); - } - return total + 1; - }, 0)} + clearSelection={clearSelection} + selectionCount={ + selectedItem.filter(item => !item.hasChildren).length + } /> )} -1 && + highlighted.index === + highlighted.parentIndex + 1 && + expandedItems.includes(parentItem) ) { this.onToggle(parentItem); } @@ -427,71 +626,86 @@ export default class NestedFilterableMultiselect extends React.Component { overflowX: 'hidden', paddingTop: '8px', }}> - {groupedByCategory( - itemsToProcess, - customCategorySorting - ).map((group, index) => { - const hasGroups = group[0] !== 'undefined' ? true : false; - const filteredItems = filterItems(group[1], { - itemToString, - inputValue, - }); - let categoryName = ''; - hasGroups - ? (categoryName = group[0].toUpperCase()) - : null; - - return ( - - {hasGroups && filteredItems.length > 0 && ( -
- - {categoryName} - -
- )} - {sortItems(filteredItems, { - selectedItems, - itemToString, - compareItems, - locale, - }).map(item => { - currentIndex++; - - if (highlightedIndex === currentIndex) { - highlighted = { item, index }; - } + {groupedByCategory(items, customCategorySorting).map( + (group, index) => { + const hasGroups = + group[0] !== 'undefined' ? true : false; + const filteredItems = filterItems(group[1], { + itemToString, + inputValue, + expandedItems, + }); + let categoryName = ''; + hasGroups + ? (categoryName = group[0].toUpperCase()) + : null; + + return ( + + {hasGroups && filteredItems.length > 0 && ( +
+ + {categoryName} + +
+ )} + {sortItems(filteredItems, { + selectedItems, + itemToString, + compareItems, + locale, + }).map((item, itemIndex, itemArr) => { + currentIndex++; + + if (highlightedIndex === currentIndex) { + const parentItem = this.getParentItem(item); + highlighted = { + item, + index: itemIndex, + parentIndex: parentItem + ? itemArr.indexOf(parentItem) + : -1, + }; + } + + const itemProps = getItemProps({ + item, + index: currentIndex, + }); + const itemText = itemToString(item); + + const isChecked = + selectedItem.filter( + selected => selected.id == item.id + ).length > 0; + const subOptions = getAllChildren(item, items); + const groupIsOpen = !!NestedFilterableMultiselect.find( + expandedItems, + item + ); + + const myCheckedOptions = subOptions.filter( + subOption => + selectedItem.filter( + selected => selected.id === subOption.id + ).length > 0 + ); + const myUncheckedOptions = subOptions.filter( + subOption => + selectedItem.filter( + selected => selected.id === subOption.id + ).length === 0 + ); - const itemProps = getItemProps({ - item, - index: currentIndex, - }); - const itemText = itemToString(item); - - const isChecked = - selectedItem.filter( - selected => selected.id == item.id - ).length > 0; - const subOptions = item.options; - const groupIsOpen = - openSections.filter(groupOpen => - isEqual(groupOpen, item) - ).length > 0; - - const myCheckedOptions = subOptions - ? item.options.filter( - subOption => subOption.checked - ) - : null; - const myUncheckedOptions = subOptions - ? item.options.filter( - subOption => !subOption.checked - ) - : null; - - return ( - + const itemStyle = item.level + ? { + paddingLeft: `${item.level * 19 + 16}px`, + } + : undefined; + + return ( { { const clickOutOfCheckBox = - subOptions && + subOptions.length > 0 && (e.target.localName !== 'label' && e.target.localName !== 'input'); if (clickOutOfCheckBox) { @@ -528,81 +742,16 @@ export default class NestedFilterableMultiselect extends React.Component { tabIndex="-1" labelText={itemText} tooltipText={showTooltip && itemText} - hasGroups={subOptions} + hasGroups={subOptions.length > 0} isExpanded={groupIsOpen} /> - - {groupIsOpen && - subOptions != undefined && - sortItems( - filterItems(subOptions, { - itemToString, - inputValue, - parent: item, - }), - { - selectedItems, - itemToString, - compareItems, - locale, - parent: item, - } - ).map((item, index) => { - const myIndex = ++currentIndex; - - if (highlightedIndex === currentIndex) { - highlighted = { item, index }; - } - - const optionsProps = getItemProps({ - item, - index: currentIndex, - }); - const isCheckedSub = myCheckedOptions.includes( - item - ); - const subOpText = itemToString(item); - const checkBoxIndex = index.toString(); - return ( - { - this.onItemChange( - item, - selectedItems, - onItemChange - ); - }} - onMouseMove={() => { - this.setState({ - highlightedIndex: myIndex, - }); - }}> - - - ); - })} - - ); - })} -
- ); - })} + ); + })} +
+ ); + } + )} )} diff --git a/src/components/MultiSelect/__tests__/NestedFilterableMultiselect-test.js b/src/components/MultiSelect/__tests__/NestedFilterableMultiselect-test.js index eccf8d4..cc59a5f 100644 --- a/src/components/MultiSelect/__tests__/NestedFilterableMultiselect-test.js +++ b/src/components/MultiSelect/__tests__/NestedFilterableMultiselect-test.js @@ -95,7 +95,7 @@ describe('NestedFilterableMultiselect', () => { wrapper.update(); expect(wrapper.find(listItemName).length).toBe(1); expect(wrapper.state().inputValue).toEqual('3'); - expect(wrapper.state().openSections).toEqual([]); + expect(wrapper.state().expandedItems).toEqual([]); }); it('should call `onChange` with each update to selected items via mouse click', () => { @@ -244,7 +244,6 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItems: [mockProps.items[0], mockProps.items[2]], }); - expect(wrapper.state().checkedSuboptions).toEqual([]); expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(2); // Clear all selection @@ -253,7 +252,6 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItems: [], }); - expect(wrapper.state().checkedSuboptions).toEqual([]); expect(wrapper.find('ListBoxSelection').exists()).toBe(false); }); }); @@ -271,10 +269,36 @@ describe('NestedFilterableMultiselect', () => { { id: 'option-id-1', label: 'Sub item 1', + options: + index > 0 + ? [ + { + id: 'suboption-id-11', + label: 'Sub-child item 11', + }, + { + id: 'suboption-id-12', + label: 'Sub-child item 12', + }, + ] + : undefined, }, { id: 'option-id-2', label: 'Sub item 2', + options: + index === 1 + ? [ + { + id: 'suboption-id-21', + label: 'Sub-child item 21', + }, + { + id: 'suboption-id-22', + label: 'Sub-child item 22', + }, + ] + : undefined, }, ], })), @@ -296,20 +320,34 @@ describe('NestedFilterableMultiselect', () => { // Expand the child items via mouse click wrapper .find('.bx--checkbox-label') - .at(0) + .at(1) + .find('span') + .simulate('click'); + expect(wrapper.find(listItemName).length).toBe(5); + // Expand the sub-child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(2) + .find('span') + .simulate('click'); + expect(wrapper.find(listItemName).length).toBe(7); + // Collapse the sub-child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(2) .find('span') .simulate('click'); expect(wrapper.find(listItemName).length).toBe(5); // Collapse the child items via mouse click wrapper .find('.bx--checkbox-label') - .at(0) + .at(1) .find('span') .simulate('click'); expect(wrapper.find(listItemName).length).toBe(3); }); - it('should filter a list of items by the input value', () => { + it('should filter a list of items by the input value (level=0)', () => { const wrapper = mount(); openMenu(wrapper); expect(wrapper.find(listItemName).length).toBe(mockProps.items.length); @@ -320,7 +358,7 @@ describe('NestedFilterableMultiselect', () => { wrapper.update(); expect(wrapper.find(listItemName).length).toBe(1); expect(wrapper.state().inputValue).toEqual('Nested item 2'); - expect(wrapper.state().openSections).toEqual([]); + expect(wrapper.state().expandedItems).toEqual([]); // An array input persists the current value wrapper.find('Downshift').prop('onInputValueChange')([], { type: Downshift.stateChangeTypes.changeInput, @@ -328,7 +366,7 @@ describe('NestedFilterableMultiselect', () => { wrapper.update(); expect(wrapper.find(listItemName).length).toBe(1); expect(wrapper.state().inputValue).toEqual('Nested item 2'); - expect(wrapper.state().openSections).toEqual([]); + expect(wrapper.state().expandedItems).toEqual([]); // Expand the child items wrapper @@ -337,9 +375,17 @@ describe('NestedFilterableMultiselect', () => { .find('span') .simulate('click'); expect(wrapper.find(listItemName).length).toBe(3); + + // Expand the sub-child items + wrapper + .find('.bx--checkbox-label') + .at(1) + .find('span') + .simulate('click'); + expect(wrapper.find(listItemName).length).toBe(5); }); - it('should filter a list of sub items by the input value', () => { + it('should filter a list of sub items by the input value (level=1)', () => { const wrapper = mount(); openMenu(wrapper); expect(wrapper.find(listItemName).length).toBe(mockProps.items.length); @@ -347,9 +393,98 @@ describe('NestedFilterableMultiselect', () => { type: Downshift.stateChangeTypes.changeInput, }); wrapper.update(); + + const expectedExpandedItems = mockProps.items.map(item => ({ + ...item, + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + })); + expect(wrapper.find(listItemName).length).toBe(6); expect(wrapper.state().inputValue).toEqual('Sub item 2'); - expect(wrapper.state().openSections).toEqual(mockProps.items); + expect(wrapper.state().expandedItems).toEqual(expectedExpandedItems); + + // Expand the sub-child items + wrapper + .find('.bx--checkbox-label') + .at(5) + .find('span') + .simulate('click'); + expect(wrapper.find(listItemName).length).toBe(8); + }); + + it('should filter a list of sub child items by the input value (level=2)', () => { + const wrapper = mount(); + openMenu(wrapper); + expect(wrapper.find(listItemName).length).toBe(mockProps.items.length); + wrapper.find('Downshift').prop('onInputValueChange')('Sub-child item 1', { + type: Downshift.stateChangeTypes.changeInput, + }); + wrapper.update(); + + const expectedExpandedItems = [ + { + ...mockProps.items[1], + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...mockProps.items[1].options[0], + level: 1, + category: mockProps.items[1].category, + parentId: mockProps.items[1].id, + id: `${mockProps.items[1].id}-${mockProps.items[1].options[0].id}`, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...mockProps.items[2], + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...mockProps.items[2].options[0], + level: 1, + category: mockProps.items[2].category, + parentId: mockProps.items[2].id, + id: `${mockProps.items[2].id}-${mockProps.items[2].options[0].id}`, + options: undefined, + checked: undefined, + hasChildren: true, + }, + ]; + + expect(wrapper.find(listItemName).length).toBe(8); + expect(wrapper.state().inputValue).toEqual('Sub-child item 1'); + expect(wrapper.state().expandedItems).toEqual(expectedExpandedItems); + + wrapper.find('Downshift').prop('onInputValueChange')('Sub-child item 2', { + type: Downshift.stateChangeTypes.changeInput, + }); + wrapper.update(); + + expect(wrapper.find(listItemName).length).toBe(4); + expect(wrapper.state().inputValue).toEqual('Sub-child item 2'); + expect(wrapper.state().expandedItems).toEqual([ + ...expectedExpandedItems, + { + ...mockProps.items[1].options[1], + level: 1, + category: mockProps.items[1].category, + parentId: mockProps.items[1].id, + id: `${mockProps.items[1].id}-${mockProps.items[1].options[1].id}`, + options: undefined, + checked: undefined, + hasChildren: true, + }, + ]); }); it('should filter all items by the input value', () => { @@ -362,7 +497,7 @@ describe('NestedFilterableMultiselect', () => { wrapper.update(); expect(wrapper.find(listItemName).length).toBe(0); expect(wrapper.state().inputValue).toEqual('xxx'); - expect(wrapper.state().openSections).toEqual([]); + expect(wrapper.state().expandedItems).toEqual([]); // No group should exist expect( @@ -388,7 +523,7 @@ describe('NestedFilterableMultiselect', () => { const wrapper = mount(); openMenu(wrapper); - // Select the first two items + // Select the first item wrapper .find('.bx--checkbox-label') .at(0) @@ -396,14 +531,16 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledTimes(1); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + checked: true, + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual( - mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })) - ); expect( wrapper .find('Checkbox') @@ -412,6 +549,7 @@ describe('NestedFilterableMultiselect', () => { ).toBe(false); expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(2); + // Select the second item wrapper .find('.bx--checkbox-label') .at(1) @@ -419,25 +557,36 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledTimes(2); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0], mockProps.items[2]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + checked: true, + })), + }, + { + ...mockProps.items[2], + options: mockProps.items[2].options.map(o => ({ + ...o, + checked: true, + options: + o.options && + o.options.map(p => ({ + ...p, + checked: true, + })), + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ...mockProps.items[2].options.map(option => ({ - ...option, - checked: true, - })), - ]); expect( wrapper .find('Checkbox') .at(1) .prop('indeterminate') ).toBe(false); - expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(4); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(5); // Un-select the next two items wrapper @@ -446,15 +595,23 @@ describe('NestedFilterableMultiselect', () => { .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(3); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[2]], + selectedItems: [ + { + ...mockProps.items[2], + options: mockProps.items[2].options.map(o => ({ + ...o, + checked: true, + options: + o.options && + o.options.map(p => ({ + ...p, + checked: true, + })), + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[2].options.map(option => ({ - ...option, - checked: true, - })), - ]); - expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(2); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(3); wrapper .find('.bx--checkbox-label') @@ -464,7 +621,6 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItems: [], }); - expect(wrapper.state().checkedSuboptions).toEqual([]); expect(wrapper.find('ListBoxSelection').exists()).toBe(false); }); @@ -485,14 +641,16 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledTimes(1); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map((o, i) => ({ + ...o, + checked: i === 0, + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual([ - { - ...mockProps.items[0].options[0], - checked: true, - }, - ]); expect( wrapper .find('Checkbox') @@ -508,14 +666,16 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledTimes(2); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + checked: true, + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual( - mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })) - ); expect( wrapper .find('Checkbox') @@ -530,14 +690,16 @@ describe('NestedFilterableMultiselect', () => { .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(3); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map((o, i) => ({ + ...o, + checked: i !== 0, + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual([ - { - ...mockProps.items[0].options[1], - checked: true, - }, - ]); expect( wrapper .find('Checkbox') @@ -554,7 +716,6 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItems: [], }); - expect(wrapper.state().checkedSuboptions).toEqual([]); expect( wrapper .find('Checkbox') @@ -564,6 +725,129 @@ describe('NestedFilterableMultiselect', () => { expect(wrapper.find('ListBoxSelection').exists()).toBe(false); }); + it('should call `onChange` with each update to selected sub child items', () => { + const wrapper = mount(); + openMenu(wrapper); + + // Expand the child items + wrapper + .find('.bx--checkbox-label') + .at(2) + .find('span') + .simulate('click'); + // Check child item 1 + wrapper + .find('.bx--checkbox-label') + .at(3) + .simulate('click'); + + expect(mockProps.onChange).toHaveBeenCalledTimes(1); + expect(mockProps.onChange).toHaveBeenCalledWith({ + selectedItems: [ + { + ...mockProps.items[1], + options: mockProps.items[1].options.map((o, i) => ({ + ...o, + checked: i === 0, + options: o.options.map(p => ({ + ...p, + checked: i === 0, + })), + })), + }, + ], + }); + expect( + wrapper.find('Checkbox[name="Nested item 1"]').prop('indeterminate') + ).toBe(true); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(2); + + // Expand sub child items + wrapper + .find('.bx--checkbox-label') + .at(3) + .find('span') + .simulate('click'); + + // Uncheck sub child 1 + wrapper + .find('Checkbox[name="Sub-child item 11"]') + .find('.bx--checkbox-label') + .simulate('click'); + + expect(mockProps.onChange).toHaveBeenCalledTimes(2); + expect(mockProps.onChange).toHaveBeenCalledWith({ + selectedItems: [ + { + ...mockProps.items[1], + options: mockProps.items[1].options.map((o, i) => ({ + ...o, + checked: false, + options: o.options.map((p, j) => ({ + ...p, + checked: i !== 0 ? false : j !== 0, + })), + })), + }, + ], + }); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 1"]').prop('indeterminate') + ).toBe(true); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(1); + + // Uncheck sub child 2 + wrapper + .find('Checkbox[name="Sub-child item 12"]') + .find('.bx--checkbox-label') + .simulate('click'); + + expect(mockProps.onChange).toHaveBeenCalledTimes(3); + expect(mockProps.onChange).toHaveBeenCalledWith({ + selectedItems: [], + }); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(false); + expect( + wrapper.find('Checkbox[name="Nested item 1"]').prop('indeterminate') + ).toBe(false); + expect(wrapper.find('ListBoxSelection').exists()).toBe(false); + + // Check sub child 1 + wrapper + .find('Checkbox[name="Sub-child item 11"]') + .find('.bx--checkbox-label') + .simulate('click'); + + expect(mockProps.onChange).toHaveBeenCalledTimes(4); + expect(mockProps.onChange).toHaveBeenCalledWith({ + selectedItems: [ + { + ...mockProps.items[1], + options: mockProps.items[1].options.map((o, i) => ({ + ...o, + checked: false, + options: o.options.map((p, j) => ({ + ...p, + checked: i !== 0 ? false : j === 0, + })), + })), + }, + ], + }); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 1"]').prop('indeterminate') + ).toBe(true); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(1); + }); + it('should clear all selections', () => { const wrapper = mount(); openMenu(wrapper); @@ -580,25 +864,36 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledTimes(2); expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0], mockProps.items[2]], + selectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + checked: true, + })), + }, + { + ...mockProps.items[2], + options: mockProps.items[2].options.map(o => ({ + ...o, + checked: true, + options: + o.options && + o.options.map(p => ({ + ...p, + checked: true, + })), + })), + }, + ], }); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ...mockProps.items[2].options.map(option => ({ - ...option, - checked: true, - })), - ]); expect( wrapper .find('Checkbox') .at(1) .prop('indeterminate') ).toBe(false); - expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(4); + expect(wrapper.find('ListBoxSelection').prop('selectionCount')).toBe(5); // Clear all selection wrapper.find('.bx--list-box__selection--multi').simulate('click'); @@ -606,7 +901,6 @@ describe('NestedFilterableMultiselect', () => { expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItems: [], }); - expect(wrapper.state().checkedSuboptions).toEqual([]); expect(wrapper.find('ListBoxSelection').exists()).toBe(false); }); @@ -614,33 +908,52 @@ describe('NestedFilterableMultiselect', () => { const wrapper = mount(); openMenu(wrapper); + // checked the item with multiple levels wrapper .find('.bx--checkbox-label') - .at(0) + .at(1) .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ]); - //expand suboptions + // checked item is now at the top + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(false); + // expand suboptions wrapper .find('.bx--checkbox-label') .at(0) .find('span') .simulate('click'); - //unselect subOption + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(false); + // unselect subOption + wrapper + .find('.bx--checkbox-label') + .at(2) + .simulate('click'); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(true); + // expand subChild wrapper .find('.bx--checkbox-label') .at(1) + .find('span') .simulate('click'); expect( - wrapper - .find('Checkbox') - .at(0) - .prop('indeterminate') + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(false); + // unselect subChild + wrapper + .find('.bx--checkbox-label') + .at(2) + .simulate('click'); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') ).toBe(true); }); @@ -648,44 +961,111 @@ describe('NestedFilterableMultiselect', () => { const wrapper = mount(); openMenu(wrapper); + // checked the item with multiple levels wrapper .find('.bx--checkbox-label') - .at(0) + .at(1) .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ]); - //expand suboptions + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') + ).toBe(true); + // checked item is now at the top, expand suboptions wrapper .find('.bx--checkbox-label') .at(0) .find('span') .simulate('click'); - //unselect 1 subOption + // unselect 1 subOption wrapper .find('.bx--checkbox-label') .at(1) .simulate('click'); expect( - wrapper - .find('Checkbox') - .at(0) - .prop('indeterminate') + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') ).toBe(true); - //unselect 2 subOption + // unselect 2 subOption wrapper .find('.bx--checkbox-label') .at(1) .simulate('click'); expect( - wrapper - .find('Checkbox') - .at(0) - .prop('checked') + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') + ).toBe(false); + }); + + it('should unselect parent if the suboptions at all levels are unselect', () => { + const wrapper = mount(); + openMenu(wrapper); + + // checked the item with multiple levels + wrapper + .find('.bx--checkbox-label') + .at(1) + .simulate('click'); + expect(mockProps.onChange).toHaveBeenCalledTimes(1); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') + ).toBe(true); + // checked item is now at the top, expand suboptions + wrapper + .find('.bx--checkbox-label') + .at(0) + .find('span') + .simulate('click'); + // unselect 1 subOption + wrapper + .find('.bx--checkbox-label') + .at(2) + .simulate('click'); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') + ).toBe(true); + // expand subChild + wrapper + .find('.bx--checkbox-label') + .at(1) + .find('span') + .simulate('click'); + // unselect 1 subChild + wrapper + .find('.bx--checkbox-label') + .at(2) + .simulate('click'); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(true); + expect(wrapper.find('Checkbox[name="Sub item 1"]').prop('checked')).toBe( + true + ); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(true); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') + ).toBe(true); + // unselect 2 subChild + wrapper + .find('.bx--checkbox-label') + .at(2) + .simulate('click'); + expect( + wrapper.find('Checkbox[name="Sub item 1"]').prop('indeterminate') + ).toBe(false); + expect(wrapper.find('Checkbox[name="Sub item 1"]').prop('checked')).toBe( + false + ); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('indeterminate') + ).toBe(false); + expect( + wrapper.find('Checkbox[name="Nested item 2"]').prop('checked') ).toBe(false); }); @@ -698,12 +1078,6 @@ describe('NestedFilterableMultiselect', () => { .at(0) .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ]); //expand suboptions wrapper .find('.bx--checkbox-label') @@ -745,12 +1119,6 @@ describe('NestedFilterableMultiselect', () => { .at(0) .simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(wrapper.state().checkedSuboptions).toEqual([ - ...mockProps.items[0].options.map(option => ({ - ...option, - checked: true, - })), - ]); expect(wrapper.find('.bx--checkbox-label').length).toEqual(3); }); @@ -774,4 +1142,529 @@ describe('NestedFilterableMultiselect', () => { expect(wrapper.state().highlightedIndex).toEqual(1); }); }); + + describe('multiselect with initial selections', () => { + beforeEach(() => { + mockProps = { + disabled: false, + items: generateItems(3, index => ({ + id: `id-${index}`, + label: `Nested item ${index}`, + value: index, + category: `category-${index % 2 === 0 ? 1 : 2}`, + options: + index === 0 + ? [ + { + id: 'option-id-1', + label: 'Sub item 1', + options: [ + { + id: 'suboption-id-11', + label: 'Sub-child item 11', + }, + { + id: 'suboption-id-12', + label: 'Sub-child item 12', + }, + ], + }, + { + id: 'option-id-2', + label: 'Sub item 2', + options: [ + { + id: 'suboption-id-21', + label: 'Sub-child item 21', + }, + { + id: 'suboption-id-22', + label: 'Sub-child item 22', + }, + ], + }, + ] + : undefined, + })), + placeholder: 'Placeholder...', + }; + }); + + it('preselect item at level 0', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[0], + }, + { + ...mockProps.items[2], + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + openMenu(wrapper); + // Expand the child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(0) + .find('span') + .simulate('click'); + // Expand the sub-child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(2) + .find('span') + .simulate('click'); + wrapper + .find('.bx--checkbox-label') + .at(1) + .find('span') + .simulate('click'); + + const checked = wrapper + .find('Checkbox') + .filterWhere(node => !!node.prop('checked')); + expect(checked.length).toEqual(8); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + { + ...props.initialSelectedItems[0], + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0].options[0], + level: 2, + parentId: 'id-0-option-id-1', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1-suboption-id-11', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[0].options[1], + level: 2, + parentId: 'id-0-option-id-1', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1-suboption-id-12', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[1], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[1].options[0], + level: 2, + parentId: 'id-0-option-id-2', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2-suboption-id-21', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[1].options[1], + level: 2, + parentId: 'id-0-option-id-2', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2-suboption-id-22', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[1], + level: 0, + options: undefined, + checked: undefined, + hasChildren: false, + }, + ]); + }); + + it('preselect item at level 1', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + checked: true, + })), + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + openMenu(wrapper); + // Expand the child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(0) + .find('span') + .simulate('click'); + // Expand the sub-child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(2) + .find('span') + .simulate('click'); + wrapper + .find('.bx--checkbox-label') + .at(1) + .find('span') + .simulate('click'); + + const checked = wrapper + .find('Checkbox') + .filterWhere(node => !!node.prop('checked')); + expect(checked.length).toEqual(7); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + { + ...props.initialSelectedItems[0], + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0].options[0], + level: 2, + parentId: 'id-0-option-id-1', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1-suboption-id-11', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[0].options[1], + level: 2, + parentId: 'id-0-option-id-1', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1-suboption-id-12', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[1], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[1].options[0], + level: 2, + parentId: 'id-0-option-id-2', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2-suboption-id-21', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[1].options[1], + level: 2, + parentId: 'id-0-option-id-2', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2-suboption-id-22', + options: undefined, + checked: undefined, + hasChildren: false, + }, + ]); + }); + + it('preselect item at level 2', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[0], + options: mockProps.items[0].options.map(o => ({ + ...o, + options: o.options.map((p, i) => ({ + ...p, + checked: i === 0, + })), + })), + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + openMenu(wrapper); + // Expand the child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(0) + .find('span') + .simulate('click'); + // Expand the sub-child items via mouse click + wrapper + .find('.bx--checkbox-label') + .at(2) + .find('span') + .simulate('click'); + wrapper + .find('.bx--checkbox-label') + .at(1) + .find('span') + .simulate('click'); + + const checked = wrapper + .find('Checkbox') + .filterWhere(node => !!node.prop('checked')); + expect(checked.length).toEqual(5); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + { + ...props.initialSelectedItems[0], + level: 0, + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[0].options[0], + level: 2, + parentId: 'id-0-option-id-1', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-1-suboption-id-11', + options: undefined, + checked: undefined, + hasChildren: false, + }, + { + ...props.initialSelectedItems[0].options[1], + level: 1, + parentId: props.initialSelectedItems[0].id, + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2', + options: undefined, + checked: undefined, + hasChildren: true, + }, + { + ...props.initialSelectedItems[0].options[1].options[0], + level: 2, + parentId: 'id-0-option-id-2', + category: props.initialSelectedItems[0].category, + id: 'id-0-option-id-2-suboption-id-21', + options: undefined, + checked: undefined, + hasChildren: false, + }, + ]); + }); + }); + + describe('multiselect with flat list', () => { + beforeEach(() => { + mockProps = { + disabled: false, + items: [ + { + id: 'id-0', + label: 'Flat item 1', + value: 0, + category: 'category-0', + level: 0, + hasChildren: true, + }, + { + id: 'id-1', + label: 'Child item 1', + value: 1, + category: 'category-0', + level: 1, + hasChildren: true, + parentId: 'id-0', + }, + { + id: 'id-2', + label: 'Subchild item 2', + value: 2, + category: 'category-0', + level: 2, + parentId: 'id-1', + }, + { + id: 'id-3', + label: 'Subchild item 3', + value: 3, + category: 'category-0', + level: 2, + parentId: 'id-1', + }, + { + id: 'id-4', + label: 'Child item 4', + value: 4, + category: 'category-0', + level: 1, + parentId: 'id-0', + }, + { + id: 'id-5', + label: 'Flat item 5', + value: 5, + category: 'category-0', + level: 0, + }, + { + id: 'id-6', + label: 'Flat item 6', + value: 6, + category: 'category-1', + level: 0, + hasChildren: true, + }, + { + id: 'id-7', + label: 'Child item 7', + value: 7, + category: 'category-1', + level: 1, + parentId: 'id-6', + }, + { + id: 'id-8', + label: 'Child item 8', + value: 8, + category: 'category-1', + level: 1, + parentId: 'id-6', + }, + ], + placeholder: 'Placeholder...', + }; + }); + + it('preselect item at level 0', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[0], + }, + { + ...mockProps.items[6], + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + mockProps.items[0], + mockProps.items[6], + mockProps.items[1], + mockProps.items[4], + mockProps.items[2], + mockProps.items[3], + mockProps.items[7], + mockProps.items[8], + ]); + }); + + it('preselect item at level 1', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[1], + }, + { + ...mockProps.items[5], + }, + { + ...mockProps.items[7], + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + mockProps.items[1], + mockProps.items[5], + mockProps.items[7], + mockProps.items[0], + mockProps.items[2], + mockProps.items[3], + mockProps.items[6], + ]); + }); + + it('preselect item at level 2', () => { + const props = { + ...mockProps, + initialSelectedItems: [ + { + ...mockProps.items[2], + }, + ], + }; + + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.instance().state.flattenedSelectedItems).toEqual([ + mockProps.items[2], + mockProps.items[0], + mockProps.items[1], + ]); + }); + }); }); diff --git a/src/components/MultiSelect/__tests__/__snapshots__/NestedFilterableMultiselect-test.js.snap b/src/components/MultiSelect/__tests__/__snapshots__/NestedFilterableMultiselect-test.js.snap index b4de334..a44647e 100644 --- a/src/components/MultiSelect/__tests__/__snapshots__/NestedFilterableMultiselect-test.js.snap +++ b/src/components/MultiSelect/__tests__/__snapshots__/NestedFilterableMultiselect-test.js.snap @@ -594,6 +594,2637 @@ exports[`NestedFilterableMultiselect multiselect with categories should render 1 `; +exports[`NestedFilterableMultiselect multiselect with flat list preselect item at level 0 1`] = ` + + + + +
+ +
+ +
+ 5 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`NestedFilterableMultiselect multiselect with flat list preselect item at level 1 1`] = ` + + + + +
+ +
+ +
+ 4 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`NestedFilterableMultiselect multiselect with flat list preselect item at level 2 1`] = ` + + + + +
+ +
+ +
+ 1 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`NestedFilterableMultiselect multiselect with initial selections preselect item at level 0 1`] = ` + + + + +
+ +
+ +
+ 5 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`NestedFilterableMultiselect multiselect with initial selections preselect item at level 1 1`] = ` + + + + +
+ +
+ +
+ 4 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`NestedFilterableMultiselect multiselect with initial selections preselect item at level 2 1`] = ` + + + + +
+ +
+ +
+ 2 + + + + Clear all selected items + + + + +
+
+ + +
+ + + + Open menu + + + + +
+
+
+
+
+
+
+
+
+`; + exports[`NestedFilterableMultiselect multiselect with suboptions should render 1`] = ` { }); it('should sort un-selected options alphabetically', () => { - const mockItems = ['d', 'c', 'b', 'a'].map(label => ({ label })); + const mockItems = ['d', 'c', 'b', 'a'].map(label => ({ id: label, label })); expect(defaultSortItems(mockItems, mockOptions)).toEqual([ { + id: 'a', label: 'a', }, { + id: 'b', label: 'b', }, { + id: 'c', label: 'c', }, { + id: 'd', label: 'd', }, ]); }); it('should sort un-selected numbers in increasing order', () => { - const mockItems = ['1', '10', '11', '2', '3'].map(label => ({ label })); + const mockItems = ['1', '10', '11', '2', '3'].map(label => ({ + id: label, + label, + })); expect(defaultSortItems(mockItems, mockOptions)).toEqual([ { + id: '1', label: '1', }, { + id: '2', label: '2', }, { + id: '3', label: '3', }, { + id: '10', label: '10', }, { + id: '11', label: '11', }, ]); @@ -55,19 +67,23 @@ describe('defaultSortItems', () => { it('should sort un-selected alpha-numeric sequences with increasing order', () => { const mockItems = ['Option 1', 'Option 10', 'Option 11', 'Option 2'].map( - label => ({ label }) + label => ({ id: label, label }) ); expect(defaultSortItems(mockItems, mockOptions)).toEqual([ { + id: 'Option 1', label: 'Option 1', }, { + id: 'Option 2', label: 'Option 2', }, { + id: 'Option 10', label: 'Option 10', }, { + id: 'Option 11', label: 'Option 11', }, ]); @@ -75,7 +91,7 @@ describe('defaultSortItems', () => { it('should order a selected item before all other options', () => { const mockItems = ['Option 1', 'Option 10', 'Option 11', 'Option 2'].map( - label => ({ label }) + label => ({ id: label, label }) ); // Set `selectedItems` to ['Option 11'] @@ -83,15 +99,19 @@ describe('defaultSortItems', () => { expect(defaultSortItems(mockItems, mockOptions)).toEqual([ { + id: 'Option 11', label: 'Option 11', }, { + id: 'Option 1', label: 'Option 1', }, { + id: 'Option 2', label: 'Option 2', }, { + id: 'Option 10', label: 'Option 10', }, ]); @@ -99,7 +119,7 @@ describe('defaultSortItems', () => { it('should sort selected items and order them before all other options', () => { const mockItems = ['Option 1', 'Option 10', 'Option 11', 'Option 2'].map( - label => ({ label }) + label => ({ id: label, label }) ); // Set `selectedItems` to ['Option 11', 'Option 2'] @@ -107,17 +127,160 @@ describe('defaultSortItems', () => { expect(defaultSortItems(mockItems, mockOptions)).toEqual([ { + id: 'Option 2', label: 'Option 2', }, { + id: 'Option 11', label: 'Option 11', }, { + id: 'Option 1', label: 'Option 1', }, { + id: 'Option 10', label: 'Option 10', }, ]); }); + + it('should sort parent and child', () => { + const mockItems = [ + { + id: 'x-a', + label: 'a', + parentId: 'x', + }, + { + id: 'x', + label: 'x', + }, + { + id: 'z-d-m', + label: 'm', + parentId: 'z-d', + }, + { + id: 'z-1', + label: 'z', + }, + { + id: 'x-b', + label: 'b', + parentId: 'x', + }, + { + id: 'y', + label: 'y', + }, + { + id: 'z-c', + label: 'c', + parentId: 'z', + }, + { + id: 'z-d', + label: 'd', + parentId: 'z', + }, + { + id: 'z', + label: 'z', + }, + { + id: 'z-c-k', + label: 'k', + parentId: 'z-c', + }, + { + id: 'z-e', + label: 'e', + parentId: 'z', + }, + { + id: 'z-a', + label: 'a', + parentId: 'z', + }, + { + id: 'z-c-l', + label: 'l', + parentId: 'z-c', + }, + { + id: 'z-d-n', + label: 'n', + parentId: 'z-d', + }, + ]; + expect(defaultSortItems(mockItems, mockOptions)).toEqual([ + { + id: 'x', + label: 'x', + }, + { + id: 'x-a', + label: 'a', + parentId: 'x', + }, + { + id: 'x-b', + label: 'b', + parentId: 'x', + }, + { + id: 'y', + label: 'y', + }, + { + id: 'z-1', + label: 'z', + }, + { + id: 'z', + label: 'z', + }, + { + id: 'z-a', + label: 'a', + parentId: 'z', + }, + { + id: 'z-c', + label: 'c', + parentId: 'z', + }, + { + id: 'z-c-k', + label: 'k', + parentId: 'z-c', + }, + { + id: 'z-c-l', + label: 'l', + parentId: 'z-c', + }, + { + id: 'z-d', + label: 'd', + parentId: 'z', + }, + { + id: 'z-d-m', + label: 'm', + parentId: 'z-d', + }, + { + id: 'z-d-n', + label: 'n', + parentId: 'z-d', + }, + { + id: 'z-e', + label: 'e', + parentId: 'z', + }, + ]); + }); }); diff --git a/src/components/MultiSelect/tools/filter.js b/src/components/MultiSelect/tools/filter.js index dc2fedf..68126ca 100644 --- a/src/components/MultiSelect/tools/filter.js +++ b/src/components/MultiSelect/tools/filter.js @@ -1,32 +1,67 @@ +import { buildHierarchy, findParent } from './sorting'; + +export const getAllChildren = (item, items) => { + const results = []; + const children = items.filter( + theItem => theItem.parentId && theItem.parentId === item.id + ); + + if (children.length > 0) { + results.push(...children); + + children.forEach(child => { + results.push(...getAllChildren(child, items)); + }); + } + + return results; +}; + export const defaultFilterItems = ( items, - { itemToString, inputValue, parent } + { itemToString, inputValue, expandedItems } ) => items.filter(item => { + const parents = buildHierarchy(item, items); + parents.pop(); + + if (parents.length > 0 && expandedItems) { + // If any parent item is not expanded, the child item should not be shown + const isExpanded = !parents.some( + parent => + !expandedItems.some(expandedItem => expandedItem.id === parent.id) + ); + if (!isExpanded) { + return false; + } + } + if (!inputValue) { return true; } - if (item.options) { + + const children = getAllChildren(item, items).filter(theItem => + itemToString(theItem) + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + if (children.length > 0) { // if any of the child item matches, the parent item should be shown - const isMatch = - item.options.filter(option => - itemToString(option) - .toLowerCase() - .includes(inputValue.toLowerCase()) - ).length > 0; - if (isMatch) { - return true; - } + return true; } - if (parent) { - // if it matches the parent, all sub items should be shown - const isMatch = itemToString(parent) - .toLowerCase() - .includes(inputValue.toLowerCase()); - if (isMatch) { + + if (parents.length > 0) { + const isVisible = parents.some(parent => + itemToString(parent) + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + if (isVisible) { + // if it matches any of the parents, all sub items should be shown return true; } } + return itemToString(item) .toLowerCase() .includes(inputValue.toLowerCase()); diff --git a/src/components/MultiSelect/tools/sorting.js b/src/components/MultiSelect/tools/sorting.js index 4d114b2..fce9385 100644 --- a/src/components/MultiSelect/tools/sorting.js +++ b/src/components/MultiSelect/tools/sorting.js @@ -1,3 +1,40 @@ +/* + * A utility to find the parent of the given item. + * + * @param {object} item + * @param {array} items + * @return {object} + */ +export const findParent = (item, items = []) => { + let parent; + if (item.parentId) { + items.some(theItem => { + if (theItem.id === item.parentId) { + parent = theItem; + return true; + } + return false; + }); + } + return parent; +}; + +/** + * A utility to build the hierarchy of the given item starting from root. + */ +export const buildHierarchy = (item, items = []) => { + const hierarchy = []; + if (item.parentId) { + const parent = findParent(item, items); + if (parent) { + const parentHierarchy = buildHierarchy(parent, items); + hierarchy.push(...parentHierarchy); + } + } + hierarchy.push(item); + return hierarchy; +}; + /** * Use the local `localCompare` with the `numeric` option to sort two, * potentially alpha-numeric, strings in a list of items. @@ -18,36 +55,61 @@ export const defaultCompareItems = (itemA, itemB, { locale }) => */ export const defaultSortItems = ( items, - { selectedItems, itemToString, compareItems, locale = 'en', parent } -) => - items.sort((itemA, itemB) => { - const hasItemA = selectedItems.includes(itemA); - const hasItemB = selectedItems.includes(itemB); - - // Prefer whichever item is in the `selectedItems` array first - if (hasItemA && !hasItemB) { - return -1; - } + { selectedItems, itemToString, compareItems, locale = 'en' } +) => { + const itemArr = [...items]; + return items.sort((itemA, itemB) => { + const hasItemA = selectedItems.some(item => item.id === itemA.id); + const hasItemB = selectedItems.some(item => item.id === itemB.id); - if (hasItemB && !hasItemA) { - return 1; - } + const hierarchyA = buildHierarchy(itemA, itemArr); + const hierarchyB = buildHierarchy(itemB, itemArr); + const depth = + hierarchyA.length > hierarchyB.length + ? hierarchyA.length + : hierarchyB.length; - if (parent) { - const checkedItemA = itemA.checked; - const checkedItemB = itemB.checked; + let compareResult = 0; + + for (let i = 0; i < depth; i += 1) { + const currentA = hierarchyA[i]; + const currentB = hierarchyB[i]; - // Prefer whichever checked item be first - if (checkedItemA && !checkedItemB) { + if (currentA && !currentB) { + // `currentA` is a child of `currentB` + // always place the child after the parent + return 1; + } else if (!currentA && currentB) { + // `currentB` is a child of `currentA` + // always place the child after the parent return -1; } - if (checkedItemB && !checkedItemA) { + const hasCurrentA = selectedItems.some(item => item.id === currentA.id); + const hasCurrentB = selectedItems.some(item => item.id === currentB.id); + + // Prefer whichever item is in the `selectedItems` array first + if (hasCurrentA && !hasCurrentB) { + return -1; + } + + if (hasCurrentB && !hasCurrentA) { return 1; } + + compareResult = compareItems( + itemToString(currentA), + itemToString(currentB), + { + locale, + } + ); + + if (compareResult !== 0) { + return compareResult; + } } - return compareItems(itemToString(itemA), itemToString(itemB), { - locale, - }); + return compareResult; }); +}; diff --git a/src/internal/Selection.js b/src/internal/Selection.js index 1f01e92..b2d0691 100644 --- a/src/internal/Selection.js +++ b/src/internal/Selection.js @@ -35,34 +35,57 @@ export default class Selection extends React.Component { }; handleSelectItem = item => { + const items = Array.isArray(item) ? item : [item]; this.internalSetState(prevState => ({ - selectedItems: [...prevState.selectedItems, item], + selectedItems: [...prevState.selectedItems, ...items], })); }; handleRemoveItem = item => { - this.internalSetState(prevState => ({ - selectedItems: prevState.selectedItems.filter( - itemOnState => itemOnState !== item - ), - })); + const items = Array.isArray(item) ? item : [item]; + this.internalSetState(prevState => { + const newState = { + selectedItems: prevState.selectedItems.filter( + itemOnState => items.indexOf(itemOnState) === -1 + ), + }; + return newState; + }); }; handleOnItemChange = item => { const { selectedItems } = this.state; - let selectedIndex; - selectedItems.forEach((selectedItem, index) => { - if (isEqual(selectedItem, item)) { - selectedIndex = index; + const itemsToProcess = Array.isArray(item) ? item : [item]; + const result = itemsToProcess.reduce( + (acc, theItem) => { + let selectedIndex; + selectedItems.some((selectedItem, index) => { + if (isEqual(selectedItem, theItem)) { + selectedIndex = index; + return true; + } + return false; + }); + if (selectedIndex === undefined) { + acc.itemsToSelect.push(theItem); + } else { + acc.itemsToRemove.push(selectedItems[selectedIndex]); + } + return acc; + }, + { + itemsToSelect: [], + itemsToRemove: [], } - }); + ); - if (selectedIndex === undefined) { - this.handleSelectItem(item); - return; + if (result.itemsToSelect.length > 0) { + this.handleSelectItem(result.itemsToSelect); + } + if (result.itemsToRemove.length > 0) { + this.handleRemoveItem(result.itemsToRemove); } - this.handleRemoveItem(item); }; render() {