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 = [
+ ,
+ ,
+ ,
+ ,
+
+];
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
+}) => (
+
+
+
+);
+
+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';