From 16dcf3456f0074ac5458cabf462593687fd4a607 Mon Sep 17 00:00:00 2001 From: laviro <1ronlavi@gmail.com> Date: Thu, 4 Oct 2018 12:05:57 +0300 Subject: [PATCH] Add DualList component --- .../less/dual-list-selector.less | 156 ++++++++ .../less/patternfly-react.less | 1 + .../patternfly-react/package.json | 4 +- .../patternfly-react/_dual-list-selector.scss | 156 ++++++++ .../patternfly-react/_patternfly-react.scss | 1 + .../components/DualListSelector/DualList.js | 357 ++++++++++++++++++ .../DualListSelector/DualList.stories.js | 66 ++++ .../DualListSelector/DualList.test.js | 158 ++++++++ .../DualListSelector/DualListControlled.js | 86 +++++ .../DualListSelector/DualListMocks.js | 33 ++ .../__snapshots__/DualList.test.js.snap | 81 ++++ .../components/DualListArrows.js | 52 +++ .../components/DualListBody.js | 50 +++ .../components/DualListCounter.js | 26 ++ .../components/DualListFilter.js | 29 ++ .../components/DualListFooter.js | 29 ++ .../components/DualListHeading.js | 67 ++++ .../components/DualListItem.js | 76 ++++ .../components/DualListItems.js | 60 +++ .../components/DualListMainCheckbox.js | 30 ++ .../components/DualListSelector.js | 14 + .../components/DualListSort.js | 38 ++ .../components/DualListSelector/constants.js | 11 + .../components/DualListSelector/helpers.js | 246 ++++++++++++ .../src/components/DualListSelector/index.js | 29 ++ .../patternfly-react/src/index.js | 1 + 26 files changed, 1856 insertions(+), 1 deletion(-) create mode 100644 packages/patternfly-3/patternfly-react/less/dual-list-selector.less create mode 100644 packages/patternfly-3/patternfly-react/sass/patternfly-react/_dual-list-selector.scss create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.stories.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.test.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListControlled.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListMocks.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/__snapshots__/DualList.test.js.snap create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListArrows.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListBody.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListCounter.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFilter.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFooter.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListHeading.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItem.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItems.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListMainCheckbox.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSelector.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSort.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/constants.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/helpers.js create mode 100644 packages/patternfly-3/patternfly-react/src/components/DualListSelector/index.js diff --git a/packages/patternfly-3/patternfly-react/less/dual-list-selector.less b/packages/patternfly-3/patternfly-react/less/dual-list-selector.less new file mode 100644 index 00000000000..b0338a93b5b --- /dev/null +++ b/packages/patternfly-3/patternfly-react/less/dual-list-selector.less @@ -0,0 +1,156 @@ +@dual-list-length: 230px; +@dual-list-border: 1px solid @color-pf-black-300; +@dual-list-light-border: 1px solid @color-pf-black-200; +@dual-list-scroll-bar-length: 12px; + +.dual-list-pf-arrows { + display: inline-block; + margin: auto; + position: relative; + bottom: 110px; + font-size: 23px; + color: @color-pf-black-400; + + @media only screen and (max-width: 600px) { + display: block; + position: inherit; + margin: 5px 0; + padding-left: 79px; + } + + span { + display: block; + margin: 25px; + cursor: pointer; + transition: color 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transform: rotate(-90deg); + + @media only screen and (max-width: 600px) { + display: inline; + margin: 0 20px 0 0; + } + + &:hover { + color: @color-pf-black-500; + } + } +} + +.dual-list-pf-body { + height: @dual-list-length; + width: @dual-list-length; + overflow-y: scroll; + overflow-x: auto; + display: inline-grid; + align-content: flex-start; + + &::-webkit-scrollbar { + width: @dual-list-scroll-bar-length; + height: @dual-list-scroll-bar-length; + background: @color-pf-black-100; + } + + &::-webkit-scrollbar-thumb { + background: @color-pf-black-300; + border-radius: 6px; + border: 3px solid transparent; + background-clip: content-box; + + &:hover { + background: @color-pf-black-400; + border-radius: 6px; + border: 3px solid transparent; + background-clip: content-box; + } + } +} + +.dual-list-pf-filter { + margin-left: 20px; + + input { + background-color: @color-pf-black-150; + border: @dual-list-light-border; + width: 145px; + padding: 0 22px 0 5px; + margin-top: 3px; + } + + .search-icon { + position: relative; + right: 20px; + bottom: 1px; + color: @color-pf-black-400; + } + + ::-webkit-input-placeholder { + font-style: italic; + } +} + +.dual-list-pf-footer { + padding: 10px; + border-top: 1px solid @color-pf-black-300; +} + +.dual-list-pf-heading { + border-bottom: @dual-list-border; +} + +.dual-list-pf-item { + padding: 5px 0; + margin-bottom: 0; + font-weight: 400; + transition: background 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), color 0.3s ease-out; + cursor: pointer; + white-space: nowrap; + + input[type='checkbox'] { + position: relative; + left: 10px; + vertical-align: top; + cursor: pointer; + } + + &.selected { + background-color: @color-pf-blue-400; + color: white; + } + + &.child { + padding-left: 22px; + } + + &:hover { + &:not(.selected) { + background-color: @color-pf-blue-100; + color: inherit; + } + } +} + +.dual-list-pf-item-label { + margin-left: 20px; +} + +.dual-list-pf-main-checkbox { + position: relative; + left: 10px; + vertical-align: text-top; + cursor: pointer; +} + +.dual-list-pf-no-items { + margin-top: 30px; + text-align: center; +} + +.dual-list-pf-selector { + display: inline-block; + border: @dual-list-border; + user-select: none; +} + +.dual-list-pf-sort-icon { + cursor: pointer; +} diff --git a/packages/patternfly-3/patternfly-react/less/patternfly-react.less b/packages/patternfly-3/patternfly-react/less/patternfly-react.less index fe9ebb52bf5..b8e82f76ab7 100644 --- a/packages/patternfly-3/patternfly-react/less/patternfly-react.less +++ b/packages/patternfly-3/patternfly-react/less/patternfly-react.less @@ -15,3 +15,4 @@ @import 'pagination'; @import 'expand-collapse'; @import 'login-page'; +@import 'dual-list-selector'; diff --git a/packages/patternfly-3/patternfly-react/package.json b/packages/patternfly-3/patternfly-react/package.json index fdc1ffc429b..f91219148f7 100644 --- a/packages/patternfly-3/patternfly-react/package.json +++ b/packages/patternfly-3/patternfly-react/package.json @@ -29,6 +29,7 @@ "classnames": "^2.2.5", "css-element-queries": "^1.0.1", "patternfly": "^3.58.0", + "lodash": "^4.17.11", "react-bootstrap": "^0.32.1", "react-bootstrap-switch": "^15.5.3", "react-bootstrap-typeahead": "^3.1.3", @@ -38,7 +39,8 @@ "react-fontawesome": "^1.6.1", "react-motion": "^0.5.2", "reactabular-table": "^8.14.0", - "recompose": "^0.26.0" + "recompose": "^0.26.0", + "uuid": "^3.3.2" }, "optionalDependencies": { "sortabular": "^1.5.1", diff --git a/packages/patternfly-3/patternfly-react/sass/patternfly-react/_dual-list-selector.scss b/packages/patternfly-3/patternfly-react/sass/patternfly-react/_dual-list-selector.scss new file mode 100644 index 00000000000..04970f8754d --- /dev/null +++ b/packages/patternfly-3/patternfly-react/sass/patternfly-react/_dual-list-selector.scss @@ -0,0 +1,156 @@ +$dual-list-length: 230px; +$dual-list-border: 1px solid $color-pf-black-300; +$dual-list-light-border: 1px solid $color-pf-black-200; +$dual-list-scroll-bar-length: 12px; + +.dual-list-pf-arrows { + display: inline-block; + margin: auto; + position: relative; + bottom: 110px; + font-size: 23px; + color: $color-pf-black-400; + + @media only screen and (max-width: 600px) { + display: block; + position: inherit; + margin: 5px 0; + padding-left: 79px; + } + + span { + display: block; + margin: 25px; + cursor: pointer; + transition: color 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transform: rotate(-90deg); + + @media only screen and (max-width: 600px) { + display: inline; + margin: 0 20px 0 0; + } + + &:hover { + color: $color-pf-black-500; + } + } +} + +.dual-list-pf-body { + height: $dual-list-length; + width: $dual-list-length; + overflow-y: scroll; + overflow-x: auto; + display: inline-grid; + align-content: flex-start; + + &::-webkit-scrollbar { + width: $dual-list-scroll-bar-length; + height: $dual-list-scroll-bar-length; + background: $color-pf-black-100; + } + + &::-webkit-scrollbar-thumb { + background: $color-pf-black-300; + border-radius: 6px; + border: 3px solid transparent; + background-clip: content-box; + + &:hover { + background: $color-pf-black-400; + border-radius: 6px; + border: 3px solid transparent; + background-clip: content-box; + } + } +} + +.dual-list-pf-filter { + margin-left: 20px; + + input { + background-color: $color-pf-black-150; + border: $dual-list-light-border; + width: 145px; + padding: 0 22px 0 5px; + margin-top: 3px; + } + + .search-icon { + position: relative; + right: 20px; + bottom: 1px; + color: $color-pf-black-400; + } + + ::-webkit-input-placeholder { + font-style: italic; + } +} + +.dual-list-pf-footer { + padding: 10px; + border-top: 1px solid $color-pf-black-300; +} + +.dual-list-pf-heading { + border-bottom: $dual-list-border; +} + +.dual-list-pf-item { + padding: 5px 0; + margin-bottom: 0; + font-weight: 400; + transition: background 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), color 0.3s ease-out; + cursor: pointer; + white-space: nowrap; + + input[type='checkbox'] { + position: relative; + left: 10px; + vertical-align: top; + cursor: pointer; + } + + &.selected { + background-color: $color-pf-blue-400; + color: white; + } + + &.child { + padding-left: 22px; + } + + &:hover { + &:not(.selected) { + background-color: $color-pf-blue-100; + color: inherit; + } + } +} + +.dual-list-pf-item-label { + margin-left: 20px; +} + +.dual-list-pf-main-checkbox { + position: relative; + left: 10px; + vertical-align: text-top; + cursor: pointer; +} + +.dual-list-pf-no-items { + margin-top: 30px; + text-align: center; +} + +.dual-list-pf-selector { + display: inline-block; + border: $dual-list-border; + user-select: none; +} + +.dual-list-pf-sort-icon { + cursor: pointer; +} diff --git a/packages/patternfly-3/patternfly-react/sass/patternfly-react/_patternfly-react.scss b/packages/patternfly-3/patternfly-react/sass/patternfly-react/_patternfly-react.scss index 086917aa120..6d8bce96362 100644 --- a/packages/patternfly-3/patternfly-react/sass/patternfly-react/_patternfly-react.scss +++ b/packages/patternfly-3/patternfly-react/sass/patternfly-react/_patternfly-react.scss @@ -15,3 +15,4 @@ @import 'pagination'; @import 'expand-collapse'; @import 'login-page'; +@import 'dual-list-selector'; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.js new file mode 100644 index 00000000000..3dd43fd00b2 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.js @@ -0,0 +1,357 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cloneDeep from 'lodash/cloneDeep'; +import DualListArrows from './components/DualListArrows'; +import DualListSelector from './components/DualListSelector'; +import { noop } from '../../common/helpers'; +import { + arrangeArray, + isAllChildrenChecked, + reverseAllItemsOrder, + getItem, + getUpdatedSelectCount, + itemHasParent, + itemHasChildren, + toggleAllItems, + isAllItemsChecked, + isItemExistOnList, + filterByHiding, + getItemsLength, + toggleFilterredItems, + getFilterredItemsLength, + makeAllItemsVisible, + getSelectedFilterredItemsLength, + isItemSelected +} from './helpers'; + +class DualList extends React.Component { + onItemChange = ({ + target: { + checked, + dataset: { position, side, parentPosition } + } + }) => { + const { + [side]: { selectCount: originalSelectCount, items: originalItems, isSortAsc, filterTerm } + } = this.props; + const items = cloneDeep(originalItems); + const item = getItem({ isSortAsc, position, items, parentPosition }); + let selectCount = originalSelectCount; + item.checked = checked; + if (itemHasParent(item)) { + const parent = getItem({ isSortAsc, position: parentPosition, items }); + parent.checked = isAllChildrenChecked(parent); + selectCount = getUpdatedSelectCount({ selectCount, checked }); + } else if (itemHasChildren(item)) { + const { children } = item; + toggleAllItems(children, checked); + selectCount = getUpdatedSelectCount({ selectCount, checked, amount: children.length }); + } else { + selectCount = getUpdatedSelectCount({ selectCount, checked }); + } + let isMainChecked = false; + if (filterTerm) { + const filteredItemsLength = getFilterredItemsLength(items); + const selectedFilteredItemsLength = getSelectedFilterredItemsLength(items); + isMainChecked = filteredItemsLength > 0 && selectedFilteredItemsLength === filteredItemsLength; + } else { + isMainChecked = isAllItemsChecked(items, selectCount); + } + this.props.onItemChange({ + side, + items, + selectCount, + isMainChecked + }); + }; + + onMainCheckboxChange = ({ + target: { + checked, + dataset: { side } + } + }) => { + const { + [side]: { items: originalItems, filterTerm, selectCount: originalSelectCount } + } = this.props; + const items = cloneDeep(originalItems); + let selectCount = originalSelectCount; + if (filterTerm) { + toggleFilterredItems(items, checked); + selectCount += getFilterredItemsLength(items) * (checked ? 1 : -1); + } else { + toggleAllItems(items, checked); + selectCount = checked ? getItemsLength(items) : 0; + } + this.props.onMainCheckboxChange({ + side, + checked, + items, + selectCount + }); + }; + + onSortClick = ({ + target: { + dataset: { side } + } + }) => { + const { + [side]: { items, isSortAsc } + } = this.props; + const itemsReversed = reverseAllItemsOrder(items); + this.props.onSortClick({ + side, + items: itemsReversed, + isSortAsc: !isSortAsc + }); + }; + + onFilterChange = ({ + target: { + value, + dataset: { side } + } + }) => { + const { + [side]: { items: originalItems, selectCount } + } = this.props; + const filterTerm = value.trim(); + if (!value) { + const items = makeAllItemsVisible(originalItems); + const isMainChecked = isAllItemsChecked(items, selectCount); + this.props.onFilterChange({ side, filterTerm, items, isMainChecked }); + return; + } + const items = filterByHiding(originalItems, filterTerm); + const filteredItemsLength = getFilterredItemsLength(items); + const isMainChecked = filteredItemsLength > 0 && getSelectedFilterredItemsLength(items) === filteredItemsLength; + this.props.onFilterChange({ side, filterTerm, items, isMainChecked }); + }; + + moveTo = otherSide => { + const side = otherSide === 'right' ? 'left' : 'right'; + const sideState = this.props[side]; + const otherSideState = this.props[otherSide]; + const sideItemsWithRemainChildren = []; + let otherSideItems = cloneDeep(otherSideState.items); + let sideItems = sideState.items.filter(item => { + if (isItemSelected(item)) { + if (itemHasChildren(item)) { + const { isParentExist, parentIndex } = isItemExistOnList(otherSideItems, item.label); + if (isParentExist) { + const { children } = otherSideItems[parentIndex]; + children.push(...item.children); + return false; + } + } + otherSideItems.push(item); + return false; + } else if (itemHasChildren(item)) { + const selectedChildren = []; + const unselectedChildren = []; + item.children.forEach(childItem => { + if (isItemSelected(childItem)) { + selectedChildren.push(childItem); + } else { + unselectedChildren.push(childItem); + } + }); + if (selectedChildren.length > 0) { + const { isParentExist, parentIndex } = isItemExistOnList(otherSideItems, item.label); + if (isParentExist) { + otherSideItems[parentIndex].children.push(...selectedChildren); + } else { + otherSideItems.push({ ...item, checked: true, children: selectedChildren }); + } + if (unselectedChildren.length > 0) { + sideItemsWithRemainChildren.push({ ...item, children: unselectedChildren }); + } + return false; + } + } + return true; + }); + if (sideItemsWithRemainChildren.length > 0) { + sideItems.push(...sideItemsWithRemainChildren); + } + sideItems = arrangeArray({ ...sideState, items: sideItems }); + otherSideItems = arrangeArray({ ...otherSideState, items: otherSideItems }); + + const updatedSideState = { + ...sideState, + items: sideItems, + selectCount: 0 + }; + + const updatedOtherSideState = { + ...otherSideState, + items: otherSideItems, + selectCount: sideState.selectCount + otherSideState.selectCount + }; + + this.props.onChange({ [side]: updatedSideState, [otherSide]: updatedOtherSideState }); + }; + + leftArrowClick = () => { + const { + arrows: { left } + } = this.props; + left.onClick(); + this.moveTo('left'); + }; + + rightArrowClick = () => { + const { + arrows: { right } + } = this.props; + right.onClick(); + this.moveTo('right'); + }; + + render() { + const { left, right, arrows } = this.props; + return ( +
+ + + +
+ ); + } +} + +DualList.propTypes = { + /** + * - items: Array of objects that must contain a label and a value. + * - options: The Kebab menu items. + * - isSortAsc: Set the list items sorting direction. + * - sortBy: set the list items sorting factor. + * - isMainChecked: Set the main checkbox state. + */ + left: PropTypes.shape({ + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + }) + ), + options: PropTypes.node, + isSortAsc: PropTypes.bool, + sortBy: PropTypes.string, + isMainChecked: PropTypes.bool + }), + /** + * - items: Array of objects that must contain a label and a value. + * - options: The Kebab menu items. + * - isSortAsc: Set the list items sorting direction. + * - isMainChecked: Set the main checkbox state. + * - sortBy: set the list items sorting factor. + */ + right: PropTypes.shape({ + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + }) + ), + options: PropTypes.node, + isSortAsc: PropTypes.bool, + sortBy: PropTypes.string, + isMainChecked: PropTypes.bool + }), + /** + * - Conatains the left and right arrows properties, + * - Where in every object there are: + * - onClick - function which determine the onClick event, + * - ariaLabel - set the aria-label text. + */ + arrows: PropTypes.shape({ + left: PropTypes.shape({ + onClick: PropTypes.func, + ariaLabel: PropTypes.string + }), + right: PropTypes.shape({ + onClick: PropTypes.func, + ariaLabel: PropTypes.string + }) + }), + /** + * Function that runs after an item was clicked. + * receives an object of: { side, items, selectCount, isMainChecked } as a callback. + */ + onItemChange: PropTypes.func, + /** + * Function that runs after the sort icon was clicked. + * receives an object of: { side, items, isSortAsc } as a callback. + */ + onSortClick: PropTypes.func, + /** + * Function that runs after the filter input has changed. + * receives an object of: { side, filterTerm } as a callback. + */ + onFilterChange: PropTypes.func, + /** + * Function that runs after the main checkbox was clicked. + * receives an object of: { side, checked, items, selectCount } as a callback. + */ + onMainCheckboxChange: PropTypes.func, + /** + * Function that runs after items have been moved between the lists. + * receives an object of: { left, right } updated sides as a callback. + */ + onChange: PropTypes.func +}; + +DualList.defaultProps = { + left: { + items: [], + options: null, + isSortAsc: true, + sortBy: 'label', + filterTerm: '', + isMainChecked: false + }, + right: { + items: [], + options: null, + isSortAsc: true, + sortBy: 'label', + filterTerm: '', + isMainChecked: false + }, + arrows: { + left: { + onClick: noop, + ariaLabel: null + }, + right: { + onClick: noop, + ariaLabel: null + } + }, + onItemChange: noop, + onSortClick: noop, + onFilterChange: noop, + onMainCheckboxChange: noop, + onChange: noop +}; + +export default DualList; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.stories.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.stories.js new file mode 100644 index 00000000000..c4d739c175b --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.stories.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { withInfo } from '@storybook/addon-info'; +import { defaultTemplate } from 'storybook/decorators/storyTemplates'; +import { storybookPackageName, DOCUMENTATION_URL, STORYBOOK_CATEGORY } from 'storybook/constants/siteConstants'; +import { name } from '../../../package.json'; +import { items, dropdownItems } from './DualListMocks'; +import { + DualList, + DualListArrows, + DualListBody, + DualListControlled, + DualListCounter, + DualListFilter, + DualListFooter, + DualListHeading, + DualListItem, + DualListItems, + DualListMainCheckbox, + DualListSelector, + DualListSort +} from './index'; + +const stories = storiesOf( + `${storybookPackageName(name)}/${STORYBOOK_CATEGORY.FORMS_AND_CONTROLS}/Dual List Selector`, + module +); + +stories.addDecorator( + defaultTemplate({ + title: 'Dual List Selector', + documentationLink: `${DOCUMENTATION_URL.PATTERNFLY_ORG_FORMS}dual-list-selector/` + }) +); + +stories.add( + 'Dual List Selector', + withInfo({ + source: false, + propTables: [ + DualList, + DualListArrows, + DualListBody, + DualListCounter, + DualListFilter, + DualListFooter, + DualListHeading, + DualListItem, + DualListItems, + DualListMainCheckbox, + DualListSort + ], + propTablesExclude: [DualListControlled, DualListSelector] + })(() => ( + + )) +); diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.test.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.test.js new file mode 100644 index 00000000000..af3bf39fbf9 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualList.test.js @@ -0,0 +1,158 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { DualListControlled } from './index'; +import { getCounterMessage as counterMessage, getFilterredItemsLength } from './helpers'; + +const getProps = () => ({ + left: { + items: [ + { + value: 'Brittany Turner', + label: 'Brittany Turner', + children: [{ value: 'zzz', label: 'zzz' }, { value: 'ppp', label: 'ppp' }] + }, + { value: 'Heather Davis', label: 'Heather Davis' } + ] + }, + right: { items: [{ value: 'Donald Trump', label: 'Donald Trump' }] } +}); + +test('dual-list render properly ', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('dual-list items match the list length ', () => { + const props = getProps(); + const component = mount(); + const selectors = component.find('.dual-list-pf-selector'); + const leftSelectorItems = selectors.first().find('.dual-list-pf-item'); + const rightSelectorItems = selectors.at(1).find('.dual-list-pf-item'); + expect(leftSelectorItems).toHaveLength(4); + expect(rightSelectorItems).toHaveLength(1); +}); + +test('Footer updates when items are selected ', () => { + const props = getProps(); + const getCounterMessage = jest.fn((selected, total) => counterMessage(selected, total)); + props.left = { ...props.left, getCounterMessage }; + const component = mount(); + expect(getCounterMessage.mock.calls).toHaveLength(1); + const footer = component.find('.dual-list-pf-footer').first(); + expect(footer.text()).toBe(counterMessage(0, 3)); + const body = component.find('.dual-list-pf-body').first(); + const listItems = body.find('.dual-list-pf-item'); + const input = listItems.first().find('input'); + const { 'data-side': side, 'data-position': position } = input.props(); + const mockedEvent = { target: { checked: true, dataset: { position, side } } }; + input.simulate('change', mockedEvent); + expect(footer.text()).toBe(getCounterMessage(2, 3)); + expect(getCounterMessage.mock.calls).toHaveLength(3); +}); + +test('selecting child items works properly', () => { + const props = getProps(); + const component = mount(); + const childItemsCheckbox = component.find('label.dual-list-pf-item .child > input[type="checkbox"]'); + const { + 'data-side': side, + 'data-position': position, + 'data-parent-position': parentPosition + } = childItemsCheckbox.first().props(); + const firstMockedEvent = { target: { checked: true, dataset: { position, side, parentPosition } } }; + const secondMockedEvent = { target: { checked: true, dataset: { position: position + 1, side, parentPosition } } }; + childItemsCheckbox.first().simulate('change', firstMockedEvent); + childItemsCheckbox.at(1).simulate('change', secondMockedEvent); + expect(component.state().left.items[0].checked).toBeTruthy(); +}); + +test('move child items works properly', () => { + const props = getProps(); + const component = mount(); + const childItemsCheckbox = component.find('label.dual-list-pf-item .child > input[type="checkbox"]'); + const { + 'data-side': side, + 'data-position': position, + 'data-parent-position': parentPosition + } = childItemsCheckbox.first().props(); + const firstMockedEvent = { target: { checked: true, dataset: { position, side, parentPosition } } }; + childItemsCheckbox.first().simulate('change', firstMockedEvent); + const rightArrow = component + .find('DualListArrows') + .find('Icon') + .first(); + rightArrow.simulate('click'); + expect(component.state().right.items[0].checked).toBeTruthy(); + expect(component.state().right.items[0].children[0].checked).toBeTruthy(); +}); + +test('dual-list filter works ', () => { + const props = getProps(); + const component = mount(); + const header = component.find('.dual-list-pf-heading').first(); + const filterInput = header + .find('.dual-list-pf-filter') + .first() + .find('input'); + const { 'data-side': side } = filterInput.props(); + const value = props.left.items[0].label.charAt(0); + const mockedEvent = { target: { value, dataset: { side } } }; + filterInput.simulate('change', mockedEvent); + expect(getFilterredItemsLength(component.state()[side].items)).toBe(2); + mockedEvent.target.value = 'test'; + filterInput.simulate('change', mockedEvent); + expect(getFilterredItemsLength(component.state()[side].items)).toBe(0); +}); + +test('main checkbox functions properly', () => { + const props = getProps(); + const component = mount(); + const selector = component.find('DualListSelector').at(1); + const itemCheckbox = selector.find('label.dual-list-pf-item > input[type="checkbox"]').first(); + const mainCheckbox = selector.find('input[type="checkbox"].dual-list-pf-main-checkbox'); + const { 'data-side': side, 'data-position': position } = itemCheckbox.props(); + const mockedEvent = { target: { checked: true, dataset: { position, side } } }; + itemCheckbox.simulate('change', mockedEvent); + expect(component.state()[side].isMainChecked).toBeTruthy(); + mockedEvent.target.checked = false; + mainCheckbox.simulate('change', mockedEvent); + expect(component.state()[side].isMainChecked).toBeFalsy(); + expect(component.state()[side].items[0].checked).toBeFalsy(); +}); + +test('transitions between selectors works!', () => { + const props = getProps(); + const component = mount(); + const selectors = component.find('DualListSelector'); + const firstItemCheckbox = selectors + .first() + .find('label.dual-list-pf-item > input[type="checkbox"]') + .first(); + const arrows = component.find('DualListArrows').find('Icon'); + const rightArrow = arrows.at(0); + const leftArrow = arrows.at(1); + const { 'data-side': side, 'data-position': position } = firstItemCheckbox.props(); + const mockedEvent = { target: { checked: true, dataset: { position, side } } }; + const getState = () => component.state(); + expect(getState().left.items).toHaveLength(2); + expect(getState().right.items).toHaveLength(1); + firstItemCheckbox.simulate('change', mockedEvent); + rightArrow.simulate('click'); + expect(getState().left.items).toHaveLength(1); + expect(getState().right.items).toHaveLength(2); + leftArrow.simulate('click'); + expect(getState().left.items).toHaveLength(2); + expect(getState().right.items).toHaveLength(1); +}); + +test('sorting works ! ', () => { + const props = getProps(); + const component = mount(); + const selector = component.find('DualListSelector').first(); + const sortingIcon = selector.find('.dual-list-pf-sort-icon').first(); + const { 'data-side': side } = sortingIcon.props(); + const mockedEvent = { target: { dataset: { side } } }; + const originalList = [...component.state()[side].items]; + sortingIcon.simulate('click', mockedEvent); + expect(component.state().left.items[0]).toBe(originalList[originalList.length - 1]); +}); diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListControlled.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListControlled.js new file mode 100644 index 00000000000..3bc5ce5d0af --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListControlled.js @@ -0,0 +1,86 @@ +import React from 'react'; +import { isEqual } from 'lodash'; +import { DualList } from './index'; +import { adjustProps } from './helpers'; + +class DualListControlled extends React.Component { + constructor(props) { + super(props); + this.state = { + prevProps: props, + ...adjustProps(props) + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + return !isEqual(nextProps, prevState.prevProps) + ? { + prevProps: nextProps, + ...adjustProps(nextProps) + } + : null; + } + + onItemChange = ({ side, items, selectCount, isMainChecked }) => { + this.setState({ + [side]: { + ...this.state[side], + items, + selectCount, + isMainChecked + } + }); + }; + + onMainCheckboxChange = ({ side, checked, items, selectCount }) => { + this.setState({ + [side]: { + ...this.state[side], + items, + selectCount, + isMainChecked: checked + } + }); + }; + + onSortClick = ({ side, items, isSortAsc }) => { + this.setState({ + [side]: { + ...this.state[side], + items, + isSortAsc + } + }); + }; + + onFilterChange = ({ side, filterTerm, items, isMainChecked }) => { + this.setState({ + [side]: { + ...this.state[side], + filterTerm, + items, + isMainChecked + } + }); + }; + + onChange = ({ left, right }) => { + this.setState({ left, right }); + }; + + render() { + return ( + + ); + } +} + +export default DualListControlled; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListMocks.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListMocks.js new file mode 100644 index 00000000000..cafb4dceced --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/DualListMocks.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { MenuItem } from '../../index'; + +export const items = { + left: [ + { value: 'Ann Little', label: 'Ann Little' }, + { value: 'Daniel Nguyen', label: 'Daniel Nguyen' }, + { value: 'Heather Davis', label: 'Heather Davis' }, + { + value: 'Brittany Turner', + label: 'Brittany Turner', + children: [{ value: 'zzz', label: 'zzz' }, { value: 'ppp', label: 'ppp' }] + }, + { value: 'George Bell', label: 'George Bell' }, + { value: 'Anna Lane', label: 'Anna Lane' }, + { value: 'Stephen Evans', label: 'Stephen Evans' }, + { value: 'Howard Patel', label: 'Howard Patel' }, + { value: 'Albert Watkins', label: 'Albert Watkins' } + ], + right: [ + { value: 'Donald Trump', label: 'Donald Trump' }, + { value: 'Barack Obama', label: 'Barack Obama' }, + { value: 'George Walker Bush', label: 'George Walker Bush' } + ] +}; + +export const dropdownItems = [ + Action, + Another Action, + Something else here, + , + Separated link +]; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/__snapshots__/DualList.test.js.snap b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/__snapshots__/DualList.test.js.snap new file mode 100644 index 00000000000..22e37ac13af --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/__snapshots__/DualList.test.js.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dual-list render properly 1`] = ` + +`; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListArrows.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListArrows.js new file mode 100644 index 00000000000..7c15132efe1 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListArrows.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '../../../index'; +import { noop } from '../../../common/helpers'; +import { LEFT_ARROW_ARIA_LABEL, RIGHT_ARROW_ARIA_LABEL } from '../constants'; + +const DualListArrows = ({ right, left }) => ( +
+ + +
+); + +DualListArrows.propTypes = { + left: PropTypes.shape({ + /** Determine what happens on left/up arrow click */ + onClick: PropTypes.func, + /** Set the left/up arrow aria-label */ + ariaLabel: PropTypes.string + }), + right: PropTypes.shape({ + onClick: PropTypes.func, + ariaLabel: PropTypes.string + }) +}; + +DualListArrows.defaultProps = { + left: { + onClick: noop, + ariaLabel: LEFT_ARROW_ARIA_LABEL + }, + right: { + onClick: noop, + ariaLabel: RIGHT_ARROW_ARIA_LABEL + } +}; + +export default DualListArrows; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListBody.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListBody.js new file mode 100644 index 00000000000..62bb55b3191 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListBody.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DualListItems from './DualListItems'; +import { NO_ITEMS_FOUND, NO_ITEMS } from '../constants'; +import { getFilterredItemsLength } from '../helpers'; +import { noop } from '../../../common/helpers'; + +const DualListBody = ({ items, filterTerm, onItemChange, side, noItemsFoundMessage, noItemsMessage }) => { + let listItems; + if (items.length > 0) { + listItems = ; + if (getFilterredItemsLength(items) === 0) { + listItems =
{noItemsFoundMessage}
; + } + } else { + listItems =
{noItemsMessage}
; + } + return
{listItems}
; +}; + +DualListBody.propTypes = { + /** An array of items to create list items elements uppon */ + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + }) + ), + /** The term which is flitering the list. */ + filterTerm: PropTypes.string, + /** A function that is running when the item selected state is toggled. */ + onItemChange: PropTypes.func, + /** The side of the selector. */ + side: PropTypes.string, + /** Sets the body's message when no items were found while filtering */ + noItemsFoundMessage: PropTypes.string, + /** Sets the body's message when there are no items at all */ + noItemsMessage: PropTypes.string +}; + +DualListBody.defaultProps = { + items: [], + filterTerm: null, + onItemChange: noop, + side: null, + noItemsFoundMessage: NO_ITEMS_FOUND, + noItemsMessage: NO_ITEMS +}; + +export default DualListBody; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListCounter.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListCounter.js new file mode 100644 index 00000000000..ba6d7fd33e3 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListCounter.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getCounterMessage as counterMessage } from '../helpers'; + +const DualListCounter = ({ selected, total, getCounterMessage }) => ( + {getCounterMessage(selected, total)} +); + +DualListCounter.propTypes = { + /** The Amount of selected items in the selector */ + selected: PropTypes.number, + /** The Amount of total items in the selectror */ + total: PropTypes.number, + /** Determines the counter message in the selector's footer, + * receives the selected and total amounts of items. + */ + getCounterMessage: PropTypes.func +}; + +DualListCounter.defaultProps = { + selected: 0, + total: 0, + getCounterMessage: counterMessage +}; + +export default DualListCounter; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFilter.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFilter.js new file mode 100644 index 00000000000..44e25a4e7be --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFilter.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '../../../index'; +import { noop } from '../../../common/helpers'; +import { FILTER_LABEL } from '../constants'; + +const DualListFilter = ({ onChange, side, placeHolder }) => ( + + + + +); + +DualListFilter.propTypes = { + /** The filter function that runs on the list items when the input changes. */ + onChange: PropTypes.func, + /** The side of the selector. */ + side: PropTypes.string, + /** Filter's placeholder. */ + placeHolder: PropTypes.string +}; + +DualListFilter.defaultProps = { + onChange: noop, + side: null, + placeHolder: FILTER_LABEL +}; + +export default DualListFilter; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFooter.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFooter.js new file mode 100644 index 00000000000..abdad69c96b --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListFooter.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DualListCounter from './DualListCounter'; +import { getCounterMessage as counterMessage, getItemsLength } from '../helpers'; + +const DualListFooter = ({ items, selectCount, getCounterMessage }) => ( +
+ +
+); + +DualListFooter.propTypes = { + /** Items array to get the length from. */ + items: PropTypes.array, + /** Amount of selected items in the selector. */ + selectCount: PropTypes.number, + /** Determines the counter message in the selector's footer, + * receives the selected and total amounts of items. + */ + getCounterMessage: PropTypes.func +}; + +DualListFooter.defaultProps = { + items: [], + selectCount: 0, + getCounterMessage: counterMessage +}; + +export default DualListFooter; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListHeading.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListHeading.js new file mode 100644 index 00000000000..764c7ad8452 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListHeading.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import UUID from 'uuid/v1'; +import { Form, DropdownKebab } from '../../../index'; +import DualListFilter from './DualListFilter'; +import DualListSort from './DualListSort'; +import DualListMainCheckbox from './DualListMainCheckbox'; +import { noop } from '../../../common/helpers'; +import { SORT_ARIA_LABEL } from '../constants'; + +const DualListHeading = ({ + isSortAsc, + onSortClick, + onFilterChange, + onMainCheckboxChange, + kebabMenu, + side, + sortAriaLabel, + isMainChecked, + kebabID +}) => ( +
+
+ + + + + {kebabMenu} + + +
+); + +DualListHeading.propTypes = { + /** Which type of sort is it to determine the right icon. */ + isSortAsc: PropTypes.bool, + /** The function which is running when sort icon is clicked. */ + onSortClick: PropTypes.func, + /** The filter function that runs on the list items when the input changes. */ + onFilterChange: PropTypes.func, + /** The function which is being called on checked state toggled. */ + onMainCheckboxChange: PropTypes.func, + /** The Kebab menu items */ + kebabMenu: PropTypes.node, + /** Which side is the selector, passed by the onClick function. */ + side: PropTypes.string, + /** Sets the aria-label of the icon. */ + sortAriaLabel: PropTypes.string, + /** controlls the main checkbox */ + isMainChecked: PropTypes.bool, + /** ID for the kebab container */ + kebabID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) +}; + +DualListHeading.defaultProps = { + isSortAsc: true, + onSortClick: noop, + onFilterChange: noop, + onMainCheckboxChange: noop, + kebabMenu: null, + side: null, + sortAriaLabel: SORT_ARIA_LABEL, + isMainChecked: false, + kebabID: `dual-list-pf-kebab-${UUID()}` +}; + +export default DualListHeading; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItem.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItem.js new file mode 100644 index 00000000000..ce031a1aaa0 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItem.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { TypeAheadSelect } from '../../../components/TypeAheadSelect'; +import { noop } from '../../../common/helpers'; + +const { Highlighter } = TypeAheadSelect; + +const DualListItem = ({ + checked, + className, + position, + parentPosition, + value, + label, + filterTerm, + onChange, + side, + hidden +}) => { + const cx = classNames('dual-list-pf-item', className, checked && ' selected'); + return ( + + ); +}; + +DualListItem.propTypes = { + /** Is the element chacked */ + checked: PropTypes.bool, + /** Additional html class */ + className: PropTypes.string, + /** The element position, used by the onChange function. */ + position: PropTypes.number, + /** The element parent position, used by the onChange function. */ + parentPosition: PropTypes.number, + /** The element value, used by the onChange function. */ + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** The element label, used by the onChange function. */ + label: PropTypes.string, + /** The term which is flitering the list. */ + filterTerm: PropTypes.string, + /** A function that is running when the item selected state is toggled. */ + onChange: PropTypes.func, + /** The side of the selector. */ + side: PropTypes.string, + /** Sets the item visibillity when filtering. */ + hidden: PropTypes.bool +}; + +DualListItem.defaultProps = { + checked: false, + className: null, + parentPosition: null, + position: 0, + value: '', + label: null, + filterTerm: null, + onChange: noop, + side: null, + hidden: false +}; + +export default DualListItem; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItems.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItems.js new file mode 100644 index 00000000000..8cac3c454d0 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListItems.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DualListItem from './DualListItem'; +import { noop } from '../../../common/helpers'; + +const DualListItems = ({ items, filterTerm, onChange, side }) => { + const menuItems = items.map((item, index) => { + const { children } = item; + return ( + + + {children && + children.map((child, childIndex) => ( + + ))} + + ); + }); + + return menuItems; +}; + +DualListItems.propTypes = { + /** An array of items to create list items elements uppon */ + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + }) + ), + /** The term which is flitering the list. */ + filterTerm: PropTypes.string, + /** A function that is running when the item selected state is toggled. */ + onChange: PropTypes.func, + /** The side of the selector. */ + side: PropTypes.string +}; + +DualListItems.defaultProps = { + items: [], + filterTerm: null, + onChange: noop, + side: null +}; + +export default DualListItems; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListMainCheckbox.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListMainCheckbox.js new file mode 100644 index 00000000000..db982d64a1b --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListMainCheckbox.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { noop } from '../../../common/helpers'; + +const DualListMainCheckbox = ({ isChecked, side, onChange }) => ( + +); + +DualListMainCheckbox.propTypes = { + /** controlls the checkbox */ + isChecked: PropTypes.bool, + /** the side of the selectors, passed in the onChange function. */ + side: PropTypes.string, + /** The function which is being called on checked state toggled. */ + onChange: PropTypes.func +}; + +DualListMainCheckbox.defaultProps = { + isChecked: false, + side: null, + onChange: noop +}; + +export default DualListMainCheckbox; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSelector.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSelector.js new file mode 100644 index 00000000000..8fc1a19828d --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSelector.js @@ -0,0 +1,14 @@ +import React from 'react'; +import DualListHeading from './DualListHeading'; +import DualListBody from './DualListBody'; +import DualListFooter from './DualListFooter'; + +const DualListSelector = props => ( +
+ + + +
+); + +export default DualListSelector; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSort.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSort.js new file mode 100644 index 00000000000..aea5651bb5b --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/components/DualListSort.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '../../..'; +import { noop } from '../../../common/helpers'; +import { SORT_ARIA_LABEL } from '../constants'; + +const DualListSort = ({ onClick, side, isSortAsc, ariaLabel }) => ( + +); + +DualListSort.propTypes = { + /** The function which is running when sort icon is clicked. */ + onClick: PropTypes.func, + /** Which side is the selector, passed by the onClick function. */ + side: PropTypes.string, + /** Which type of sort is it to determine the right icon. */ + isSortAsc: PropTypes.bool, + /** Sets the aria-label of the icon. */ + ariaLabel: PropTypes.string +}; + +DualListSort.defaultProps = { + onClick: noop, + side: null, + isSortAsc: true, + ariaLabel: SORT_ARIA_LABEL +}; + +export default DualListSort; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/constants.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/constants.js new file mode 100644 index 00000000000..fb49c90cbac --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/constants.js @@ -0,0 +1,11 @@ +export const NO_ITEMS_FOUND = 'No items were found'; + +export const NO_ITEMS = 'No items to show'; + +export const SORT_ARIA_LABEL = 'reverse-sort-order'; + +export const FILTER_LABEL = 'Filter'; + +export const RIGHT_ARROW_ARIA_LABEL = 'Remove Selection'; + +export const LEFT_ARROW_ARIA_LABEL = 'Add Selection'; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/helpers.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/helpers.js new file mode 100644 index 00000000000..45136e8de49 --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/helpers.js @@ -0,0 +1,246 @@ +export const filterByHiding = (list, value) => { + const filterValue = value.toLowerCase(); + return list.map(item => { + const itemLabel = item.label.toLowerCase(); + const included = itemLabel.includes(filterValue); + // if the item label matches the filter value. + item.hidden = !included; + // if it is a parent and its label doesn't match the filter value. + if (itemHasChildren(item)) { + if (isItemHidden(item)) { + let childrenIncludedAmount = 0; + item.children.forEach(childItem => { + const childLabel = childItem.label.toLowerCase(); + const childIncluded = childLabel.includes(filterValue); + childItem.hidden = !childIncluded; + childrenIncludedAmount += childIncluded ? 1 : 0; + }); + item.hidden = childrenIncludedAmount === 0; + } else { + item.children = makeAllItemsVisible(item.children); + } + } + return item; + }); +}; + +export const makeAllItemsVisible = list => + list.map(item => { + item.hidden = false; + if (itemHasChildren(item)) { + item.children.forEach(childItem => { + childItem.hidden = false; + }); + } + return item; + }); + +export const sortItems = (items, sortFactor = 'label') => + items.sort((a, b) => (a[sortFactor].toLowerCase() > b[sortFactor].toLowerCase() ? 1 : -1)); + +export const arrangeArray = ({ items, sortBy, isSortAsc = true, isMainChecked = false }) => { + // sort the items + let itemsCopy = sortItems(items, sortBy).map((item, index) => { + // add position to the item and update if the main checkbox is initialy checked. + const modifiedItem = { + ...item, + position: index, + checked: item.checked || isMainChecked + }; + if (itemHasChildren(item)) { + // sort the children array and add a position, parentPosition and update check state. + modifiedItem.children = sortItems(item.children, sortBy).map((child, childIndex) => ({ + ...child, + position: childIndex, + parentPosition: index, + checked: child.checked || isMainChecked + })); + } + return modifiedItem; + }); + + itemsCopy = isSortAsc ? itemsCopy : reverseAllItemsOrder(itemsCopy); + + return itemsCopy; +}; + +export const getDefaultProps = () => ({ + items: [], + options: null, + isSortAsc: true, + sortBy: 'label', + filterTerm: '', + isMainChecked: false +}); + +export const getCheckedAmount = ({ items }) => { + let checkedAmount = 0; + items.forEach(item => { + if (isItemSelected(item)) { + checkedAmount += 1; + if (itemHasChildren(item)) { + checkedAmount += item.children.length; + } + } else if (itemHasChildren(item)) { + item.children.forEach(child => { + if (isItemSelected(child)) { + checkedAmount += 1; + } + }); + } + }); + return checkedAmount; +}; +export const getCounterMessage = (selected, total) => `${selected} of ${total} items selected`; + +export const adjustProps = ({ left, right, ...props }) => { + const defaultProps = getDefaultProps(); + const leftItems = arrangeArray({ ...left }); + const rightItems = arrangeArray({ ...right }); + return { + ...props, + left: { + ...defaultProps, + ...left, + items: leftItems, + selectCount: getCheckedAmount({ ...left }) + }, + right: { + ...defaultProps, + ...right, + items: rightItems, + selectCount: getCheckedAmount({ ...right }) + } + }; +}; + +export const isAllChildrenChecked = ({ children }) => + children.filter(({ checked }) => checked).length === children.length; + +export const getItemsLength = items => { + let { length } = items; + if (length === 0) { + return 0; + } + items.forEach(({ children }) => { + if (children) { + // add the children amount and reduce the parent. + length += children.length - 1; + } + }); + return length; +}; + +export const reverseAllItemsOrder = items => { + const reversedItems = [...items].reverse(); + return reversedItems.map(item => (item.children ? { ...item, children: item.children.reverse() } : item)); +}; + +export const getItem = ({ isSortAsc, items, position, parentPosition }) => { + // if item is a child. + if (parentPosition !== undefined) { + const parent = items[getItemPosition(items, parentPosition, isSortAsc)]; + return parent.children[getItemPosition(parent.children, position, isSortAsc)]; + } + return items[getItemPosition(items, position, isSortAsc)]; +}; + +export const getUpdatedSelectCount = ({ selectCount, checked, amount = 1 }) => + selectCount + (checked ? amount : -1 * amount); + +export const itemHasParent = item => item.parentPosition !== undefined; + +export const itemHasChildren = item => item.children !== undefined; + +export const getItemPosition = (array, position, isSortAsc) => (isSortAsc ? position : array.length - position - 1); + +export const toggleAllItems = (list, checked) => { + list.forEach(item => { + item.checked = checked; + if (itemHasChildren(item)) { + item.children.forEach(childItem => { + childItem.checked = checked; + }); + } + return item; + }); +}; + +export const isAllItemsChecked = (items, selectCount) => selectCount > 0 && selectCount === getItemsLength(items); + +export const isItemExistOnList = (list, itemLabel) => { + let parentIndex = null; + // find if the parent already exist on the list. + list.forEach((listItem, index) => { + if (listItem.label === itemLabel) { + parentIndex = index; + } + }); + return { isParentExist: parentIndex !== null, parentIndex }; +}; + +export const toggleFilterredItems = (list, checked) => { + list.forEach(item => { + if (!isItemHidden(item)) { + item.checked = checked; + if (itemHasChildren(item)) { + toggleAllItems(item.children, checked); + } + } else if (itemHasChildren(item)) { + item.children.forEach(childItem => { + if (!isItemHidden(childItem)) { + item.checked = checked; + } + }); + } + }); +}; + +export const getFilteredItems = list => { + const filteredItems = []; + list.forEach(item => { + if (!isItemHidden(item)) { + filteredItems.push(item); + } else if (itemHasChildren(item)) { + const filteredChildren = []; + item.children.forEach(childItem => { + if (!isItemHidden(childItem)) { + filteredChildren.push(childItem); + } + }); + if (filteredChildren.length > 0) { + filteredItems.push({ ...item, children: filteredChildren }); + } + } + }); + + return filteredItems; +}; + +export const getFilterredItemsLength = list => getItemsLength(getFilteredItems(list)); + +export const getSelectedFilterredItemsLength = list => { + const filteredItems = getFilteredItems(list); + let selectedAmount = 0; + filteredItems.forEach(item => { + if (isItemSelected(item)) { + selectedAmount += 1; + if (itemHasChildren(item)) { + let selectedChildrenAmount = 0; + item.children.forEach(childItem => { + if (isItemSelected(childItem)) { + selectedChildrenAmount += 1; + } + }); + if (selectedChildrenAmount) { + selectedAmount += selectedChildrenAmount - 1; + } + } + } + }); + return selectedAmount; +}; + +export const isItemSelected = item => item.checked; + +export const isItemHidden = item => item.hidden; diff --git a/packages/patternfly-3/patternfly-react/src/components/DualListSelector/index.js b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/index.js new file mode 100644 index 00000000000..f00b0d6764f --- /dev/null +++ b/packages/patternfly-3/patternfly-react/src/components/DualListSelector/index.js @@ -0,0 +1,29 @@ +import DualList from './DualList'; +import DualListArrows from './components/DualListArrows'; +import DualListBody from './components/DualListBody'; +import DualListControlled from './DualListControlled'; +import DualListCounter from './components/DualListCounter'; +import DualListFilter from './components/DualListFilter'; +import DualListFooter from './components/DualListFooter'; +import DualListHeading from './components/DualListHeading'; +import DualListItem from './components/DualListItem'; +import DualListItems from './components/DualListItems'; +import DualListMainCheckbox from './components/DualListMainCheckbox'; +import DualListSelector from './components/DualListSelector'; +import DualListSort from './components/DualListSort'; + +export { + DualList, + DualListArrows, + DualListBody, + DualListControlled, + DualListCounter, + DualListFilter, + DualListFooter, + DualListHeading, + DualListItem, + DualListItems, + DualListMainCheckbox, + DualListSelector, + DualListSort +}; diff --git a/packages/patternfly-3/patternfly-react/src/index.js b/packages/patternfly-3/patternfly-react/src/index.js index edf41d2cf87..99ae5180c92 100644 --- a/packages/patternfly-3/patternfly-react/src/index.js +++ b/packages/patternfly-3/patternfly-react/src/index.js @@ -10,6 +10,7 @@ export * from './components/Cards'; export * from './components/Chart'; export * from './components/Dropdown'; export * from './components/DropdownKebab'; +export * from './components/DualListSelector'; export * from './components/EmptyState'; export * from './components/FieldLevelHelp'; export * from './components/Filter';