From b9be5e45a2986b528f701a9b2e3cb4670c7f0b37 Mon Sep 17 00:00:00 2001 From: talyak Date: Sun, 18 Mar 2018 08:37:50 +0200 Subject: [PATCH] basic code for react-multi-selection --- .babelrc | 19 + .eslintrc | 13 + .gitignore | 13 + .idea/vcs.xml | 6 + .npmignore | 20 + README.md | 33 + config/enzyme/enzyme_setup.js | 8 + config/enzyme/tempPolyfills.js | 5 + config/jest/fileMock.js | 1 + config/jest/jest.config.json | 31 + config/webpack/loaders.js | 43 ++ config/webpack/plugins.js | 16 + config/webpack/webpack.common.js | 24 + config/webpack/webpack.dev.js | 9 + config/webpack/webpack.prod.js | 8 + package.json | 95 +++ src/draggable_items.js | 118 ++++ src/draggable_items.scss | 16 + src/multiselection_group.js | 42 ++ src/multiselection_group.scss | 27 + src/multiselection_items.js | 101 +++ src/multiselection_items.scss | 20 + src/multiselection_list.constants.js | 6 + src/multiselection_list.js | 620 ++++++++++++++++++ src/multiselection_list.scss | 120 ++++ src/multiselection_list_utils.js | 19 + src/multiselection_navigation.js | 35 + src/multiselection_navigation.scss | 20 + src/multiselection_virtualized_items.js | 80 +++ src/multiselection_virtualized_items.scss | 20 + .../multiselection_group.spec.js.snap | 45 ++ .../multiselection_items.spec.js.snap | 199 ++++++ test/multiselection_group.spec.js | 35 + test/multiselection_items.spec.js | 148 +++++ test/multiselection_list.spec.js | 468 +++++++++++++ test/multiselection_list_utils.spec.js | 60 ++ 36 files changed, 2543 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .idea/vcs.xml create mode 100644 .npmignore create mode 100644 README.md create mode 100644 config/enzyme/enzyme_setup.js create mode 100644 config/enzyme/tempPolyfills.js create mode 100644 config/jest/fileMock.js create mode 100644 config/jest/jest.config.json create mode 100644 config/webpack/loaders.js create mode 100644 config/webpack/plugins.js create mode 100644 config/webpack/webpack.common.js create mode 100644 config/webpack/webpack.dev.js create mode 100644 config/webpack/webpack.prod.js create mode 100644 package.json create mode 100644 src/draggable_items.js create mode 100644 src/draggable_items.scss create mode 100644 src/multiselection_group.js create mode 100644 src/multiselection_group.scss create mode 100644 src/multiselection_items.js create mode 100644 src/multiselection_items.scss create mode 100644 src/multiselection_list.constants.js create mode 100644 src/multiselection_list.js create mode 100644 src/multiselection_list.scss create mode 100644 src/multiselection_list_utils.js create mode 100644 src/multiselection_navigation.js create mode 100644 src/multiselection_navigation.scss create mode 100644 src/multiselection_virtualized_items.js create mode 100644 src/multiselection_virtualized_items.scss create mode 100644 test/__snapshots__/multiselection_group.spec.js.snap create mode 100644 test/__snapshots__/multiselection_items.spec.js.snap create mode 100644 test/multiselection_group.spec.js create mode 100644 test/multiselection_items.spec.js create mode 100644 test/multiselection_list.spec.js create mode 100644 test/multiselection_list_utils.spec.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..26bff80 --- /dev/null +++ b/.babelrc @@ -0,0 +1,19 @@ +{ + "presets": ["babel-preset-react", "babel-preset-env", "stage-2"], + "plugins": ["transform-object-rest-spread"], + + "env": { + "test": { + "plugins": [ + [ + "babel-plugin-webpack-loaders", + { + "config": "./test.webpack.config.js", + "verbose": false + } + ], + "rewire" + ] + } + } +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..39cf85c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,13 @@ +{ + "plugins": [ + "prettier", + "react" + ], + "rules": { + "prettier/prettier": "error" + }, + "extends": [ + "prettier" + ], + "parser": "babel-eslint" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..feddc99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.swp +*~ +*.iml +.*.haste_cache.* +.DS_Store +.idea +npm-debug.log +node_modules +dist +out +coverage +test/report.xml +junit.xml \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..81cc5b4 --- /dev/null +++ b/.npmignore @@ -0,0 +1,20 @@ +*.swp +*~ +*.iml +.*.haste_cache.* +.DS_Store +.idea +.babelrc +.eslintrc +.storybook +mentionk.yml +test.webpack.config.js +npm-debug.log +lib +test +buildscripts +config +semantic_release +coverage +stories +src \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..90433f4 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Multi selection list + +Kenshoo multi selection list component. + + + +```jsx +import {MultiSelectionList} from 'kenshoo-shared'; + +const Something = () => ( +
+ +
+); +``` + + +## Properties + +| Name | Type | Default | Description | +|:----- |:----- |:----- |:----- | +| `items` | `List` | [] | list of items . | +| `selectedIds` | `Array` | [] | selected list to start with (subgroup of items). +| `searchPlaceholder` | `String` | 'Search...' | Search box place holder | +| `emptyText` | `String` | 'No items...' | Text to display when list is empty | +| `sortFn` | `function` | undefined | list item auto sorting (on items changed) | +| `onOrderChanged` | `function` | ()=>{} | callback for order changed event (by navigation buttons or drag-n-drop) | +| `withNavigation` | `boolean` | false | toggle to show navigation buttons in list | +| `groups` | `List` | [] | list of objects. Groups will appear as titles for items. Example of object: {id: 1, label: 'A', selectGroupLabel: 'Select All', itemIds:[1, 2]}] | +| `isItemLockedFn` | `function` | (item)=>false | function to define whether item should be blocked for navigation | +| `sumItemsInPageForLazyLoad` | `number` | 100 | actual for lazyLoad props - sum of list items for single page | +| `msDelayOnChangeFilter` | `number` | null -(if lazyload true by default passed 600 ms)| onChange is delayed before performing a function as the number of ms this value contains | +| `withSelectAll` | `boolean` | false | toggle to show select All option in list. \ No newline at end of file diff --git a/config/enzyme/enzyme_setup.js b/config/enzyme/enzyme_setup.js new file mode 100644 index 0000000..97c5ba6 --- /dev/null +++ b/config/enzyme/enzyme_setup.js @@ -0,0 +1,8 @@ +// TODO: Remove this `raf` polyfill once the below issue is sorted +// https://github.com/facebookincubator/create-react-app/issues/3199#issuecomment-332842582 +import raf from './tempPolyfills' + +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/config/enzyme/tempPolyfills.js b/config/enzyme/tempPolyfills.js new file mode 100644 index 0000000..c90e217 --- /dev/null +++ b/config/enzyme/tempPolyfills.js @@ -0,0 +1,5 @@ +const raf = global.requestAnimationFrame = (cb) => { + setTimeout(cb, 0) +} + +export default raf \ No newline at end of file diff --git a/config/jest/fileMock.js b/config/jest/fileMock.js new file mode 100644 index 0000000..0a445d0 --- /dev/null +++ b/config/jest/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/config/jest/jest.config.json b/config/jest/jest.config.json new file mode 100644 index 0000000..67f4ddb --- /dev/null +++ b/config/jest/jest.config.json @@ -0,0 +1,31 @@ +{ + "verbose": true, + "setupFiles": [ + "./config/enzyme/enzyme_setup" + ], + "testResultsProcessor": "jest-junit", + "rootDir": "../../", + "moduleFileExtensions": [ + "js", + "jsx" + ], + "moduleDirectories": [ + "node_modules", + "bower_components", + "shared" + ], + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "./config/jest/fileMock.js", + "\\.(css|scss)$": "identity-obj-proxy" + }, + "collectCoverageFrom" : [ + "**/src/**/*.{js,jsx}", + "!**/buildscripts/**", + "!**/config/**", + "!**/node_modules/**", + "!**/resources/**", + "!**/stories/**", + "!**/coverage/**" + ] +} \ No newline at end of file diff --git a/config/webpack/loaders.js b/config/webpack/loaders.js new file mode 100644 index 0000000..add5d32 --- /dev/null +++ b/config/webpack/loaders.js @@ -0,0 +1,43 @@ +const babelLoader = { + test: /(\.jsx|\.js)$/, + loader: "babel-loader", + exclude: /(node_modules(?!\/webpack-dev-server)|bower_components)/ +}; + +const cssLoader = { + test: /(\.scss|\.css)/, + loaders: [ + "style-loader", + "css-loader?modules&importLoaders=1&localIdentName=kn-[name]__[local]___[hash:base64:5]", + "sass-loader" + ] +}; + +const pngLoader = { + test: /\.png$/, + loader: "url-loader?limit=10000&mimetype=image/png" +}; + +const mdLoader = { + test: /\.md$/, + loader: "raw" +}; + +const jsonLoader = { + test: /\.json$/, + loader: "json" +}; + +const svgLoader = { + test: /\.svg$/, + loader: "svg-inline-loader?classPrefix" +}; + +module.exports = { + babelLoader, + cssLoader, + pngLoader, + mdLoader, + jsonLoader, + svgLoader +}; diff --git a/config/webpack/plugins.js b/config/webpack/plugins.js new file mode 100644 index 0000000..b8f256c --- /dev/null +++ b/config/webpack/plugins.js @@ -0,0 +1,16 @@ +const path = require("path"); +const webpack = require("webpack"); + +const definePlugin = env => + new webpack.DefinePlugin({ + "process.env": { + NODE_ENV: JSON.stringify(env) + } + }); + +const ignorePlugin = new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/); + +module.exports = { + definePlugin, + ignorePlugin +}; diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js new file mode 100644 index 0000000..77f4317 --- /dev/null +++ b/config/webpack/webpack.common.js @@ -0,0 +1,24 @@ +const path = require("path"); +const { babelLoader, cssLoader, mdLoader, pngLoader, jsonLoader, svgLoader } = require("./loaders"); +const { ignorePlugin } = require("./plugins"); + +module.exports = { + entry: { + index: "./src/index.js", + table: "./src/components/table/index.js", + }, + plugins: [ignorePlugin], + externals: { + react: "react", + "react-dom": "react-dom" + }, + output: { + filename: "[name].js", + path: path.resolve(process.cwd(), "dist"), + library: "kenshoo-shared", + libraryTarget: "commonjs2" + }, + module: { + rules: [babelLoader, cssLoader, mdLoader, pngLoader, jsonLoader, svgLoader] + } +}; diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js new file mode 100644 index 0000000..210242d --- /dev/null +++ b/config/webpack/webpack.dev.js @@ -0,0 +1,9 @@ +const merge = require("webpack-merge"); + +const common = require("./webpack.common.js"); +const { definePlugin } = require("./plugins"); + +module.exports = merge(common, { + plugins: [definePlugin("dev")], + devtool: "inline-source-map", +}); diff --git a/config/webpack/webpack.prod.js b/config/webpack/webpack.prod.js new file mode 100644 index 0000000..e4f74b3 --- /dev/null +++ b/config/webpack/webpack.prod.js @@ -0,0 +1,8 @@ +const merge = require("webpack-merge"); + +const common = require("./webpack.common.js"); +const { definePlugin } = require("./plugins"); + +module.exports = merge(common, { + plugins: [definePlugin("production")] +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7def5a3 --- /dev/null +++ b/package.json @@ -0,0 +1,95 @@ +{ + "name": "multi-selection", + "version": "0.0.0-development", + "description": "React List To List", + "repository": "https://github.com/kenshoo/react-shared.git", + "author": "Kenshoo", + "license": "MIT", + "main": "index.js", + "scripts": { + "build": "webpack --config config/webpack/webpack.dev.js", + "build:stats": "NODE_ENV=production webpack --config config/webpack/webpack.prod.js -p --bail --json > stats.json", + "test": "cross-env BABEL_DISABLE_CACHE=1 jest --config config/jest/jest.config.json", + "test:watch": "cross-env BABEL_DISABLE_CACHE=1 jest --watchAll --config config/jest/jest.config.json", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook", + "lint:dev": "eslint --ext js --ext jsx ./src --fix", + "lint:jenkins": "eslint --ext js --ext jsx ./src", + "semantic-release": "semantic-release pre && npm publish && semantic-release post" + }, + "devDependencies": { + "@kadira/react-storybook-addon-info": "^3.3.0", + "@storybook/addon-knobs": "3.2.12", + "@storybook/addon-options": "3.2.12", + "@storybook/addons": "3.2.12", + "@storybook/react": "3.2.12", + "babel-cli": "^6.6.4", + "babel-core": "^6.7.4", + "babel-eslint": "^8.0.1", + "babel-loader": "^7.1.2", + "babel-plugin-rewire": "^1.1.0", + "babel-plugin-transform-object-rest-spread": "~6.26.0", + "babel-plugin-webpack-loaders": "^0.9.0", + "babel-preset-env": "~1.6.0", + "babel-preset-react": "~6.24.1", + "babel-preset-stage-2": "^6.24.1", + "chai-enzyme": "^0.8.0", + "cheerio": "^1.0.0-rc.2", + "cross-env": "^5.0.5", + "css-loader": "^0.28.7", + "enzyme": "^3.1.0", + "enzyme-adapter-react-16": "^1.1.0", + "eslint": "^4.8.0", + "eslint-config-prettier": "^2.9.0", + "eslint-plugin-prettier": "^2.3.1", + "eslint-plugin-react": "^7.5.1", + "identity-obj-proxy": "^3.0.0", + "ignore-styles": "^5.0.1", + "isomorphic-fetch": "^2.2.1", + "jest": "^21.2.1", + "jest-cli": "^22.0.6", + "jest-junit": "^3.1.0", + "jest-sandbox": "^1.1.1", + "jsdom": "^11.3.0", + "json-loader": "^0.5.4", + "node-sass": "^4.5.0", + "nodemon": "^1.9.1", + "postcss-loader": "^2.0.6", + "prettier": "^1.8.2", + "raw-loader": "^0.5.1", + "react": "^16.0.0", + "react-addons-test-utils": "^15.0.0", + "react-addons-update": "^15.4.1", + "react-dom": "^16.0.0", + "react-test-renderer": "^16.0.0", + "sass-loader": "^6.0.6", + "semantic-release": "^8.2.0", + "storybook-readme": "3.0.6", + "style-loader": "^0.18.2", + "svg-inline-loader": "^0.8.0", + "webpack": "^3.6.0", + "webpack-cli": "^2.0.12", + "webpack-dev-server": "^2.2.0", + "webpack-merge": "^4.1.1", + "webpack-strip": "^0.1.0" + }, + "peerDependencies": { + "react": "^15.0.0-0 || ^16.0.0", + "react-dom": "^15.0.0-0 || ^16.0.0" + }, + "dependencies": { + "classnames": "2.2.5", + "material-ui": "^1.0.0-beta.25", + "prop-types": "^15.5.8" + }, + "publishConfig": { + "access": "restricted" + }, + "release": { + "debug": false, + "analyzeCommits": "./semantic_release/analyzeCommits", + "verifyConditions": { + "path": "./semantic_release/verifyConditions" + } + } +} diff --git a/src/draggable_items.js b/src/draggable_items.js new file mode 100644 index 0000000..443a2ea --- /dev/null +++ b/src/draggable_items.js @@ -0,0 +1,118 @@ +import React, { PureComponent } from "react"; +import { findDOMNode } from "react-dom"; +import { DragSource, DropTarget } from "react-dnd"; +import DragDropContext from "../dragndropcontext/dragndrop_context"; +import DraggableItem from "../draggableitem/draggable_item"; +import { PLACEHOLDER_ID } from "./multiselection_list.constants"; + +import styles from "./draggable_items.scss"; + +class DraggableItems extends PureComponent { + constructor(props) { + super(props); + this.state = { placeholderIndex: null }; + + this.isForbiddenIndex = this.isForbiddenIndex.bind(this); + this.onHoverLoose = this.onHoverLoose.bind(this); + this.onItemEndDrag = this.onItemEndDrag.bind(this); + this.onItemDragHover = this.onItemDragHover.bind(this); + this.onItemDragDrop = this.onItemDragDrop.bind(this); + } + + componentWillReceiveProps() { + this.setState({ placeholderIndex: null }); + } + + onItemDragHover(dragIndex, hoverIndex) { + const { placeholderIndex } = this.state; + if (!this.isForbiddenIndex(hoverIndex) && placeholderIndex !== hoverIndex) { + this.setState({ placeholderIndex: hoverIndex }); + } + } + + onItemEndDrag() { + this.setState({ placeholderIndex: null }); + } + + onItemDragDrop() { + const hoverIndex = this.state.placeholderIndex; + if (hoverIndex != null) { + this.props.dragSelectedItems(hoverIndex); + } + } + + onHoverLoose(index) { + const hoverIndex = this.state.placeholderIndex; + if (hoverIndex == index) { + this.setState({ placeholderIndex: null }); + } + } + + isForbiddenIndex(targetIndex) { + const { items, isItemLockedFn } = this.props; + const lockedItemsCount = items.filter(isItemLockedFn).length; + + if (targetIndex < lockedItemsCount) { + return true; + } + + const forbiddenIndeces = this.getForbiddenHoverIndexes(); + return forbiddenIndeces.includes(targetIndex); + } + + getForbiddenHoverIndexes() { + const { selected, items } = this.props; + + const selectionNeighbors = selected.reduce((result, itemId) => { + const selectedIndex = items.findIndex(item => item.id == itemId); + return result.concat(selectedIndex, selectedIndex + 1); + }, []); + + return Array.from(new Set(selectionNeighbors)); + } + + placeholderDisplayFn() { + return
  • ; + } + + renderItem(item, index) { + const { itemDisplayFn, isItemLockedFn } = this.props; + const isDraggable = !isItemLockedFn(item); + const displayFn = item.isPlaceholder + ? this.placeholderDisplayFn + : itemDisplayFn; + + return ( + + ); + } + + renderItems() { + const itemsToShow = [...this.props.items]; + + if (this.state.placeholderIndex) { + const { placeholderIndex } = this.state; + const placeholder = { id: PLACEHOLDER_ID, isPlaceholder: true }; + itemsToShow.splice(placeholderIndex, 0, placeholder); + } + + return itemsToShow.map((item, index) => this.renderItem(item, index)); + } + + render() { + return
      {this.renderItems()}
    ; + } +} + +export default DragDropContext(DraggableItems); diff --git a/src/draggable_items.scss b/src/draggable_items.scss new file mode 100644 index 0000000..828bedb --- /dev/null +++ b/src/draggable_items.scss @@ -0,0 +1,16 @@ +@import '../../../resources/scss/globals'; + +$placeholder_height: 30px; + +.list_items { + list-style: none; + padding: 0; + margin: 0; +} + +.item_placeholder { + height: $placeholder_height; + color: $color-black; + border: 1px dashed $palette_gray_50; + background-color: $palette_gray_1; +} diff --git a/src/multiselection_group.js b/src/multiselection_group.js new file mode 100644 index 0000000..2d11dcd --- /dev/null +++ b/src/multiselection_group.js @@ -0,0 +1,42 @@ +import React, { PureComponent } from "react"; +import classNames from "classnames/bind"; +import styles from "./multiselection_group.scss"; +import BootstrapTooltip from "../tooltip/bootstraptooltip/bootstrap_tooltip"; + +class ListGroup extends PureComponent { + constructor(props) { + super(props); + this.onGroupLinkClick = this.onGroupLinkClick.bind(this); + } + + onGroupLinkClick() { + const { groupItemIds, onGroupLinkClick } = this.props; + onGroupLinkClick && onGroupLinkClick(groupItemIds); + } + + render() { + const { label, groupLink } = this.props; + return ( +
  • + + {groupLink} + + + {label} + +
  • + ); + } +} + +ListGroup.defaultProps = { + label: "Group", + groupLink: "Select All", + groupItemIds: [] +}; + +export default ListGroup; diff --git a/src/multiselection_group.scss b/src/multiselection_group.scss new file mode 100644 index 0000000..3e0babf --- /dev/null +++ b/src/multiselection_group.scss @@ -0,0 +1,27 @@ +@import '../../../resources/scss/globals'; + +$group_padding: 10px; +$group_font_color: $palette_gray_300; +$group_height: 30px; +$group_font_size: 12px; + +.list_group { + height: $group_height; + line-height: $group_height; + padding: 0 $group_padding; + color: $group_font_color; + font-size: $group_font_size; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + cursor: default; +} + +.group_select_all { + float: right; + cursor: pointer; + color: $palette_blue_400; + position: relative; + z-index: auto; + padding-left: 5px; +} \ No newline at end of file diff --git a/src/multiselection_items.js b/src/multiselection_items.js new file mode 100644 index 0000000..6631417 --- /dev/null +++ b/src/multiselection_items.js @@ -0,0 +1,101 @@ +import React, { PureComponent } from "react"; +import ListGroup from "./multiselection_group"; +import DraggableItems from "./draggable_items"; +import PropTypes from "prop-types"; + +import styles from "./multiselection_items.scss"; + +class ListItems extends PureComponent { + itemsWithGroupsReducer(accumulator, group) { + const { + items, + itemDisplayFn, + selected, + onSelectGroupClick, + onDeselectGroupClick + } = this.props; + + const groupItems = items.filter(({ id }) => group.itemIds.includes(id)); + if (groupItems.length === 0) { + return accumulator; + } + + const notSelectedItems = groupItems.filter( + item => !selected.includes(item.id) + ); + const isGroupSelected = notSelectedItems.length == 0; + const groupLink = isGroupSelected + ? this.props.deselectGroupLabel + : this.props.selectGroupLabel; + const onGroupLinkClick = isGroupSelected + ? onDeselectGroupClick + : onSelectGroupClick; + + return accumulator.concat([ + , + ...groupItems.map(item => itemDisplayFn(item)) + ]); + } + + emptyList() { + const { filterResultsText, emptyText, searchTerm } = this.props; + const hasFilter = searchTerm.length; + return ( +
    + {hasFilter ? filterResultsText : emptyText} +
    + ); + } + + renderListItems() { + const { groups, itemDisplayFn } = this.props; + if (groups && groups.length) { + return groups.reduce( + (accumulator, group) => this.itemsWithGroupsReducer(accumulator, group), + [] + ); + } + + const { items } = this.props; + return items.map(itemDisplayFn); + } + + render() { + const { items } = this.props; + if (!items.length) { + return this.emptyList(); + } + + const { withNavigation, searchTerm } = this.props; + if (withNavigation && !searchTerm) { + return ( + + ); + } + + return
      {this.renderListItems()}
    ; + } +} + +ListItems.propTypes = { + selected: PropTypes.array +}; + +ListItems.defaultProps = { + selected: [] +}; + +export default ListItems; diff --git a/src/multiselection_items.scss b/src/multiselection_items.scss new file mode 100644 index 0000000..d952d6e --- /dev/null +++ b/src/multiselection_items.scss @@ -0,0 +1,20 @@ +@import '../../../resources/scss/globals'; + +$list_font_size: 12px; +$list_width: 250px; + +.list_items { + list-style: none; + padding: 0; + margin: 0; +} + +.no_items { + font-size: $list_font_size; + color: $palette_gray_300; + display: flex; + height: 100%; + min-width: $list_width; + align-items: center; + justify-content: center; +} diff --git a/src/multiselection_list.constants.js b/src/multiselection_list.constants.js new file mode 100644 index 0000000..b571051 --- /dev/null +++ b/src/multiselection_list.constants.js @@ -0,0 +1,6 @@ +export const MOVE = { + UP: "UP", + DOWN: "DOWN" +}; + +export const PLACEHOLDER_ID = -1; diff --git a/src/multiselection_list.js b/src/multiselection_list.js new file mode 100644 index 0000000..085edc2 --- /dev/null +++ b/src/multiselection_list.js @@ -0,0 +1,620 @@ +import React, { PureComponent } from "react"; +import pull from "lodash/pull"; +import find from "lodash/find"; +import findIndex from "lodash/findIndex"; +import union from "lodash/union"; +import without from "lodash/without"; +import xor from "lodash/xor"; +import PropTypes from "prop-types"; +import Waypoint from "react-waypoint"; +import classNames from "classnames/bind"; +import isEmpty from "is-empty"; +import Input from "../input/input_text"; +import styles from "./multiselection_list.scss"; +import * as Themes from "../../themes/themes"; +import { DotsLoader } from "../../components/loaders/dots"; +import ListNavigation from "./multiselection_navigation"; +import ListItems from "./multiselection_items"; +import BootstrapTooltip from "../tooltip/bootstraptooltip/bootstrap_tooltip"; +import { getIconClass } from "../../common/icons/icons"; +import { MOVE } from "./multiselection_list.constants"; +import VirtualizedListItems from "./multiselection_virtualized_items"; +import { isAllSelected } from "./multiselection_list_utils"; + +export const DEFAULT_MS_DELAY_ONCHANGE_FOR_LAZY_LOADING = 600; + +class MultiSelectionList extends PureComponent { + constructor(props) { + super(props); + const { items, selectedIds } = props; + this.state = { + selected: selectedIds, + searchTerm: "", + lastSelectedIndex: null, + items, + selectedAll: this.calculateSelectedAll(selectedIds, items) + }; + + this.handleFilter = this.handleFilter.bind(this); + this.onSelectAllClick = this.onSelectAllClick.bind(this); + this.onSelectGroupClick = this.onSelectGroupClick.bind(this); + this.onDeselectGroupClick = this.onDeselectGroupClick.bind(this); + this.onEnter = this.onEnter.bind(this); + this.handleExternalFilter = this.handleExternalFilter.bind(this); + this.onSearchTermChange = this.onSearchTermChange.bind(this); + this.changeSelectedState = this.changeSelectedState.bind(this); + this.moveItem.bind(this); + this.dragSelectedItems.bind(this); + this.item.bind(this); + } + + componentWillReceiveProps({ items, selectedIds, customFilter }) { + if ( + this.isItemsNotChanged(this.props.items, items) && + xor(selectedIds, this.props.selectedIds).length == 0 + ) { + return; + } + + if (customFilter) { + this.setState( + { + items, + selectedAll: this.calculateSelectedAll(this.state.selected, items) + }, + () => { + if (this.state.searchTerm) { + this.handleFilter(this.state.searchTerm); + } + } + ); + return; + } + + this.setState( + { + selected: selectedIds, + lastSelectedIndex: null, + items, + selectedAll: this.calculateSelectedAll(selectedIds, items) + }, + () => { + this.onSelectedChange(); + if (this.state.searchTerm) { + this.handleFilter(this.state.searchTerm, true); + } + } + ); + } + + componentDidUpdate() { + if (this.props.isVirtualized) { + this.virtualizedListItemsRef.triggerForceUpdateGrid(); + } + } + + isItemsNotChanged(prevItems, nextItems) { + return nextItems === prevItems || xor(nextItems, prevItems).length == 0; + } + + onEnter(props) { + if (this.state.items.length >= this.props.sumItemsInPageForLazyLoad) { + this.props.onEnter(props, this.state.items.length, this.state.searchTerm); + } + } + + selectItem(item) { + return () => { + if (this.props.isItemLockedFn(item)) { + return; + } + + const { id } = item; + if (this.state.selected.includes(id)) { + return; + } + + const selected = this.state.selected.splice(0); + selected.push(id); + + this.setState({ selected }, this.onSelectedChange); + }; + } + + waypoint() { + return ; + } + + handleMultiSelect(id) { + const selected = [...this.state.selected]; + const clickedIndex = findIndex(this.state.items, { id }); + const { lastSelectedIndex, items } = this.state; + const { isItemLockedFn } = this.props; + + const fromIndex = Math.min( + clickedIndex, + lastSelectedIndex == null + ? items.filter(isItemLockedFn).length + : lastSelectedIndex + ); + const toIndex = Math.max(clickedIndex, lastSelectedIndex); + + for (let i = fromIndex; i <= toIndex; i++) { + if (!selected.includes(items[i].id)) { + selected.push(items[i].id); + } + } + + this.setState({ selected }, this.onSelectedChange); + } + + toggleItemSelected(item) { + return e => { + if (this.props.isItemLockedFn(item)) { + return; + } + + const { id } = item; + if (e.shiftKey) { + return this.handleMultiSelect(id); + } + + const selected = [...this.state.selected]; + + if (selected.includes(id)) { + pull(selected, id); + } else { + selected.push(id); + const lastSelectedIndex = findIndex(this.state.items, { id }); + + this.setState({ + lastSelectedIndex + }); + } + + this.setState({ selected }, this.onSelectedChange); + }; + } + + selectSingle(item) { + return () => { + if (this.props.isItemLockedFn(item)) { + return; + } + + const { id } = item; + const { onDoubleClick } = this.props; + + this.setState({ selected: [id] }, () => { + this.onSelectedChange(); + onDoubleClick(); + }); + }; + } + + handleFilter(value, forceFilterSelected = false) { + const { items, filterFn } = this.props; + const { selected } = this.state; + + const newList = items.filter(filterFn(value)); + + this.setState( + { + items: newList, + searchTerm: value, + ...(this.props.filterSelected || forceFilterSelected + ? { selected: selected.filter(id => find(newList, { id })) } + : {}) + }, + this.onSelectedChange + ); + } + + handleExternalFilter(value) { + this.setState( + { + searchTerm: value + }, + this.props.onFilterChange(value) + ); + } + + changeSelectedState(selected) { + this.setState({ selected }, this.alignSelectedAll); + } + + onSelectAllClick() { + const { selectedAll, items, selected } = this.state; + const newSelected = + selectedAll === true + ? selected.filter(id => !find(items, { id })) + : union(selected, items.map(({ id }) => id)); + + this.setState({ selected: newSelected }, this.onSelectedChange); + } + + onSelectGroupClick(groupItemIds) { + const { items, selected } = this.state; + const newSelected = items + .map(({ id }) => id) + .filter(id => groupItemIds.includes(id)); + + this.setState( + { selected: union(selected, newSelected) }, + this.onSelectedChange + ); + } + + onDeselectGroupClick(groupItemIds) { + const { selected } = this.state; + const newSelected = selected.filter(id => !groupItemIds.includes(id)); + this.setState({ selected: newSelected }, this.onSelectedChange); + } + + onOrderChanged() { + this.props.onOrderChanged(this.state.items); + } + + onSelectedChange() { + const { groups, isVirtualized } = this.props; + const withGrouping = groups && groups.length > 0; + this.props.onSelect(this.state.selected); + this.alignSelectedAll(); + + if (!withGrouping) { + this.sortItems(); + } + } + + sortItems() { + const { items } = this.state; + const { sortFn } = this.props; + + this.setState({ items: sortFn(items) }); + } + + sortByGroups(items) { + const { sortFn, groups } = this.props; + const sortedItems = sortFn(items); + return groups.reduce((accumulator, group) => { + const groupItems = sortedItems.filter(({ id }) => + group.itemIds.includes(id) + ); + return accumulator.concat(groupItems); + }, []); + } + + alignSelectedAll() { + this.setState({ selectedAll: this.calculateSelectedAll() }); + } + + calculateSelectedAll( + selected = this.state.selected, + items = this.state.items + ) { + return isAllSelected(selected, items); + } + + item(item) { + const isGrouped = this.props.groups && this.props.groups.length > 0; + const isSelected = this.state.selected.includes(item.id); + const isLocked = this.props.isItemLockedFn(item); + const classes = classNames(styles.list_item, this.props.itemClassName, { + [styles.locked]: isLocked, + [styles.selected]: isSelected, + [this.props.selectedItemClassName]: isSelected, + [styles.grouped]: isGrouped + }); + + return ( +
  • + {this.props.displayFn(item)} +
  • + ); + } + + loader() { + return ( +
    + +
    + ); + } + + onSearchTermChange(event) { + const { lazyLoad } = this.props; + const newValue = event.target.value; + lazyLoad + ? this.handleExternalFilter(newValue) + : this.handleFilter(newValue); + } + + dragSelectedItems(hoverIndex) { + const { selected } = this.state; + const items = [...this.state.items]; + + const newItemsList = items.filter(item => !selected.includes(item.id)); + const selectedItems = items.filter(item => selected.includes(item.id)); + const aboveHoverIndexCount = items.filter( + (item, index) => selected.includes(item.id) && index < hoverIndex + ).length; + const targetIndex = hoverIndex - aboveHoverIndexCount; + newItemsList.splice(targetIndex, 0, ...selectedItems); + + this.setState({ items: newItemsList }, this.onOrderChanged); + } + + moveItem(direction) { + return () => { + if (!this.state.selected || !this.state.selected.length) return; + + const selectedItemId = this.state.selected[0]; + const oldLocation = findIndex(this.state.items, { id: selectedItemId }); + const newLocation = + direction == MOVE.UP ? oldLocation - 1 : oldLocation + 1; + const selectedItem = this.state.items[oldLocation]; + + const newItemsList = without(this.state.items, selectedItem); + newItemsList.splice(newLocation, 0, selectedItem); + + this.setState( + { + items: newItemsList + }, + this.onOrderChanged + ); + }; + } + + listNavigation() { + const disabledNavigation = { + up: + !this.isSelectedSingleItem() || + this.isSelectedFirstItem() || + this.state.searchTerm, + + down: + !this.isSelectedSingleItem() || + this.isSelectedLastItem() || + this.state.searchTerm + }; + + return ( + + ); + } + + isSelectedSingleItem() { + return ( + this.state.selected && + this.state.selected.length && + this.state.selected.length == 1 + ); + } + + isSelectedFirstItem() { + const { isItemLockedFn } = this.props; + const lockedItemsCount = this.state.items.filter(isItemLockedFn).length; + const id = this.state.selected[0]; + return findIndex(this.state.items, { id }) == lockedItemsCount; + } + + isSelectedLastItem() { + const id = this.state.selected[0]; + return findIndex(this.state.items, { id }) == this.state.items.length - 1; + } + + listFilter() { + const { + searchPlaceholder, + loading, + searchInputClassName, + searchIconClassName, + searchWrapperClassName, + msDelayOnChangeFilter, + lazyLoad + } = this.props; + + return ( +
    + +
    + ); + } + + errorTooltip(tooltipMessage) { + const helpClass = classNames(getIconClass("questionmark")); + const tooltipContent = ( +
    + {tooltipMessage} +
    + ); + + return ( + +   + + ); + } + + render() { + const { + error, + className, + loading, + withSearch, + withSelectAll, + selectAllClassName, + displaySelectAllFn, + lazyLoad, + withNavigation, + isItemLockedFn, + groups, + filterResultsText, + emptyText, + isVirtualized, + listHeight, + listRowHeight + } = this.props; + + const listContainerClasses = classNames.bind(styles)("list_container", { + error: error && error.hasErrors + }); + + const listContainerClass = classNames(listContainerClasses, className); + return ( +
    + {withSearch && this.listFilter()} + {withNavigation && this.listNavigation()} + +
    + {withSelectAll && ( +
    + {displaySelectAllFn(this.state.selectedAll)} +
    + )} + + {loading ? ( +
    {this.loader()}
    + ) : isVirtualized ? ( +
    + { + this.virtualizedListItemsRef = list; + }} + listHeight={listHeight} + listRowHeight={listRowHeight} + items={this.state.items} + selected={this.state.selected} + itemDisplayFn={this.item.bind(this)} + filterResultsText={filterResultsText} + emptyText={emptyText} + searchTerm={this.state.searchTerm} + /> + {lazyLoad && this.waypoint()} +
    + ) : ( +
    + + {lazyLoad && this.waypoint()} +
    + )} + {error && + error.errorMessage && ( + + {error.errorMessage} + {error.errorTooltip && this.errorTooltip(error.errorTooltip)} + + )} +
    +
    + ); + } +} + +MultiSelectionList.propTypes = { + items: PropTypes.array, + selectedIds: PropTypes.array, + searchPlaceholder: PropTypes.string, + emptyText: PropTypes.string, + filterResultsText: PropTypes.string, + displayFn: PropTypes.func, + displaySelectAllFn: PropTypes.func, + filterFn: PropTypes.func, + sortFn: PropTypes.func, + onDoubleClick: PropTypes.func, + withSearch: PropTypes.bool, + filterSelected: PropTypes.bool, + onSelect: PropTypes.func, + onOrderChanged: PropTypes.func, + onFilterChange: PropTypes.func, + onEnter: PropTypes.func, + groups: PropTypes.array, + sumItemsInPageForLazyLoad: PropTypes.number, + msDelayOnChangeFilter: PropTypes.number +}; + +MultiSelectionList.defaultProps = { + items: [], + selectedIds: [], + searchPlaceholder: "Search...", + emptyText: "No items...", + filterResultsText: "No available items...", + selectGroupLabel: "Select All", + deselectGroupLabel: "Deselect All", + displayFn: item => item.id, + isItemLockedFn: item => false, + displaySelectAllFn: selectedAll => + `${(selectedAll === true && "✓") || + ((selectedAll === "partial" && "-") || "")} All`, + filterFn: value => item => + String(item.id) + .toLowerCase() + .includes(value.toLowerCase()), + onDoubleClick: () => {}, + withSearch: true, + filterSelected: true, + sortFn: items => items, + onSelect: () => {}, + onOrderChanged: () => {}, + onFilterChange: () => {}, + onEnter: () => {}, + withNavigation: false, + groups: [], + sumItemsInPageForLazyLoad: 100, + isVirtualized: false +}; + +export default MultiSelectionList; diff --git a/src/multiselection_list.scss b/src/multiselection_list.scss new file mode 100644 index 0000000..0002aec --- /dev/null +++ b/src/multiselection_list.scss @@ -0,0 +1,120 @@ +@import '../../../resources/scss/globals'; +@import '../../../resources/scss/scrollbar'; + +$item_font_color: $palette_gray_400; +$item_padding: 10px; +$grouped_item_padding: 20px; +$item_height: 30px; +$list_height: 218px; +$navigation_list_height: 178px; +$list_width: 250px; +$list_font_size: 12px; +$list_error_color: $palette_red_300; + +.multi_selection_list { + color: $item_font_color; +} + +.list_filter_container { + margin-bottom: 10px; + position: relative; +} + +.list_box { + position: relative; +} + +.select_all { + border: 1px solid $palette_gray_50; + border-bottom: 0; + height: $item_height; + line-height: $item_height; + padding: 0 $item_padding; + color: $item_font_color; + font-size: $list_font_size; + cursor: pointer; + + &:hover { + background-color: $palette_green_400; + color: $color-black; + } +} + +.list_container { + @extend .shared_scrollbar; + height: $list_height; + overflow-y: auto; + border: 1px solid $palette_gray_50; + min-width: $list_width; + position: relative; + outline: none; + + &.error { + border-color: $list_error_color; + border-left: $list_error_color 1px solid + } +} + +.list_error_message { + position: absolute; + left: 0; + bottom: -19px; + color: $list_error_color; + font-size: $list_font_size; + line-height: $list_font_size; +} + +.list_error_tooltip { + max-width: 250px; +} + +.list_item { + width: 100%; + height: $item_height; + line-height: $item_height; + padding: 0 $item_padding; + color: $item_font_color; + font-size: $list_font_size; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease-in-out; + + & > div { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + &.selected { + background-color: $palette_green_60; + color: $color-black; + } + + &.grouped { + padding-left: $grouped_item_padding; + width: initial; + } + + &:hover:not(.selected) { + background-color: $palette_green_400; + color: $color-black; + } + + &.locked, &.locked:hover { + cursor: default; + color: $palette_gray_200; + background: $palette_gray_10; + } +} + +.loader_container { + position: absolute; + width: 100%; + height: 100%; + background-color: rgba($color_white, 0.5); + top: 0; + left: 0; + z-index: 99; +} diff --git a/src/multiselection_list_utils.js b/src/multiselection_list_utils.js new file mode 100644 index 0000000..430b4ad --- /dev/null +++ b/src/multiselection_list_utils.js @@ -0,0 +1,19 @@ +import intersectionBy from "lodash/intersectionBy"; + +export const PARTIAL = "partial"; + +export const isAllSelected = (selected, items) => { + const intersection = intersectionBy( + selected, + items, + item => (typeof item === "object" ? item.id : item) + ); + + if (!intersection.length) { + return false; + } else if (intersection.length === items.length) { + return true; + } else { + return PARTIAL; + } +}; diff --git a/src/multiselection_navigation.js b/src/multiselection_navigation.js new file mode 100644 index 0000000..e546bba --- /dev/null +++ b/src/multiselection_navigation.js @@ -0,0 +1,35 @@ +import React from "react"; +import Button from "../button/button"; +import { getFontAwesomeClass } from "../../common/icons/icons"; +import classNames from "classnames/bind"; +import * as Themes from "../../themes/themes"; +import styles from "./multiselection_navigation.scss"; +import { MOVE } from "./multiselection_list.constants"; + +export const ListNavigation = ({ onNavigationClick, disabledNavigation }) => { + const upButton = { theme: Themes.kgray, disabled: disabledNavigation.up }; + const downButton = { theme: Themes.kgray, disabled: disabledNavigation.down }; + + return ( +
    +
    + ); +}; + +export default ListNavigation; diff --git a/src/multiselection_navigation.scss b/src/multiselection_navigation.scss new file mode 100644 index 0000000..687fd33 --- /dev/null +++ b/src/multiselection_navigation.scss @@ -0,0 +1,20 @@ +@import '../../../resources/scss/colors.scss'; +$button_size: 30px; + +.multiselection_navigation { + display: initial; + width: $button_size; + justify-content: center; +} + +.multiselection_navigation_control { + font-size: 14px; + cursor: pointer; + width: $button_size !important; + min-width: $button_size !important; + height: $button_size; + line-height: $button_size; + border: 1px solid $palette_gray_400; + padding: 0 !important; + margin: 0 7px 10px 0; +} diff --git a/src/multiselection_virtualized_items.js b/src/multiselection_virtualized_items.js new file mode 100644 index 0000000..f8c7081 --- /dev/null +++ b/src/multiselection_virtualized_items.js @@ -0,0 +1,80 @@ +import React, { PureComponent } from "react"; +import styles from "./multiselection_virtualized_items.scss"; +import { AutoSizer, List } from "react-virtualized"; +import PropTypes from "prop-types"; +import CheckboxListToList from "../checkboxlisttolist/checkbox_list_to_list"; + +export const OVERSCAN_ROW_COUNT = 10; + +export default class VirtualizedListItems extends PureComponent { + constructor(props, context) { + super(props, context); + + this.state = { + listHeight: this.props.listHeight, + listRowHeight: this.props.listRowHeight, + overscanRowCount: OVERSCAN_ROW_COUNT, + rowCount: this.props.items.length, + scrollToIndex: undefined, + showScrollingPlaceholder: false, + useDynamicRowHeight: false + }; + + this.noRowsRenderer = this.noRowsRenderer.bind(this); + this.rowRenderer = this.rowRenderer.bind(this); + } + triggerForceUpdateGrid() { + this.listRef.forceUpdateGrid(); + } + + noRowsRenderer() { + return
    {this.props.emptyText}
    ; + } + + rowRenderer({ index, isScrolling, key, style }) { + const item = this.props.items[index]; + + return ( +
    + {this.props.itemDisplayFn(item)} +
    + ); + } + + render() { + const { listHeight, listRowHeight, overscanRowCount } = this.state; + + const rowCount = this.props.items.length; + + return ( +
    + + {({ width }) => ( + { + this.listRef = list; + }} + className={styles.list_item} + height={listHeight} + overscanRowCount={overscanRowCount} + noRowsRenderer={this.noRowsRenderer} + rowCount={rowCount} + rowHeight={listRowHeight} + rowRenderer={this.rowRenderer} + width={width} + /> + )} + +
    + ); + } +} + +VirtualizedListItems.propTypes = { + listHeight: PropTypes.number.isRequired, + listRowHeight: PropTypes.number.isRequired +}; + +VirtualizedListItems.defaultProps = { + items: [] +}; diff --git a/src/multiselection_virtualized_items.scss b/src/multiselection_virtualized_items.scss new file mode 100644 index 0000000..5f029bc --- /dev/null +++ b/src/multiselection_virtualized_items.scss @@ -0,0 +1,20 @@ +@import '../../../resources/scss/globals'; + +$list_font_size: 12px; +$list_width: 250px; + +.list_item { + list-style: none; + padding: 0; + margin: 0; +} + +.no_items { + font-size: $list_font_size; + color: $palette_gray_300; + display: flex; + height: 100%; + min-width: $list_width; + align-items: center; + justify-content: center; +} diff --git a/test/__snapshots__/multiselection_group.spec.js.snap b/test/__snapshots__/multiselection_group.spec.js.snap new file mode 100644 index 0000000..ffd2634 --- /dev/null +++ b/test/__snapshots__/multiselection_group.spec.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ListGroup component renders by default 1`] = ` +
  • + + Select All + + + + Group + + +
  • +`; + +exports[`ListGroup component renders by input props 1`] = ` +
  • + + testSelectGroupLabel + + + + testLabel + + +
  • +`; diff --git a/test/__snapshots__/multiselection_items.spec.js.snap b/test/__snapshots__/multiselection_items.spec.js.snap new file mode 100644 index 0000000..def847e --- /dev/null +++ b/test/__snapshots__/multiselection_items.spec.js.snap @@ -0,0 +1,199 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ListItems component passes prop to group case all group items selected 1`] = ` + +`; + +exports[`ListItems component passes prop to group case no group items selected 1`] = ` + +`; + +exports[`ListItems component passes prop to group case several group items selected 1`] = ` + +`; + +exports[`ListItems component renders right amount of groups 1`] = ` + +`; + +exports[`ListItems component renders with items 1`] = ` +
      + + processed-first + + + processed-second + +
    +`; + +exports[`ListItems component renders with items of group 1`] = ` +
      +
    • + + Select All + + + + Group 1 + + +
    • + + processed-first + + + processed-second + +
    +`; + +exports[`ListItems component renders without items 1`] = ` +
    + testEmptyText +
    +`; + +exports[`ListItems component renders without items and with search term 1`] = ` +
    + testFilterResultsText +
    +`; diff --git a/test/multiselection_group.spec.js b/test/multiselection_group.spec.js new file mode 100644 index 0000000..f4cb4ef --- /dev/null +++ b/test/multiselection_group.spec.js @@ -0,0 +1,35 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; + +import ListGroup from "../../../src/components/multiselectionlist/multiselection_group"; +import ShallowRenderer from "react-test-renderer/shallow"; + +const renderer = new ShallowRenderer(); + +describe("ListGroup component", () => { + test("renders by default", () => { + const component = renderer.render(); + expect(component).toMatchSnapshot(); + }); + + test("renders by input props", () => { + const inputProps = { + label: "testLabel", + groupLink: "testSelectGroupLabel" + }; + const component = renderer.render(); + expect(component).toMatchSnapshot(); + }); + + test("handles onGroupLinkClick right", () => { + let isOnGroupLinkClick = false; + const onGroupLinkClick = () => (isOnGroupLinkClick = true); + const listGroup = mount(); + + listGroup + .find("a") + .props() + .onClick(); + expect(isOnGroupLinkClick).toBe(true); + }); +}); diff --git a/test/multiselection_items.spec.js b/test/multiselection_items.spec.js new file mode 100644 index 0000000..7b4d480 --- /dev/null +++ b/test/multiselection_items.spec.js @@ -0,0 +1,148 @@ +import React from "react"; +import { mount } from "enzyme"; +import renderer from "react-test-renderer"; + +import ListItems from "../../../src/components/multiselectionlist/multiselection_items"; +import ListGroup from "../../../src/components/multiselectionlist/multiselection_group"; + +describe("ListItems component", () => { + test("renders without items", () => { + const inputItems = []; + const inputEmptyText = "testEmptyText"; + const component = renderer.create( + + ); + expect(component).toMatchSnapshot(); + }); + + test("renders without items and with search term", () => { + const inputItems = []; + const inputFilterResultsText = "testFilterResultsText"; + const component = renderer.create( + + ); + expect(component).toMatchSnapshot(); + }); + + test("renders with items", () => { + const inputItems = ["first", "second"]; + const itemDisplayFn = item => ( + {`processed-${item}`} + ); + const component = renderer.create( + + ); + + expect(component).toMatchSnapshot(); + }); + + test("renders with items of group", () => { + const inputItems = [{ id: 1, label: "first" }, { id: 2, label: "second" }]; + const inputGroups = [{ id: 3, label: "Group 1", itemIds: [1, 2] }]; + const itemDisplayFn = item => ( + {`processed-${item.label}`} + ); + const component = renderer.create( + + ); + expect(component).toMatchSnapshot(); + }); + + test("renders right amount of groups", () => { + const items = [{ id: 1, label: "first" }]; + const itemDisplayFn = item => ( + {item.label} + ); + const groups = [ + { id: 3, label: "Group 1", itemIds: [1] }, + { id: 4, label: "Group 2", itemIds: [] } + ]; + + const inputProps = { items, groups, itemDisplayFn, searchTerm: "" }; + const component = renderer.create(); + expect(component).toMatchSnapshot(); + }); + + test("passes prop to group case no group items selected", () => { + const items = [{ id: 1, label: "first" }]; + const groups = [{ id: 3, label: "Group 1", itemIds: [1] }]; + const itemDisplayFn = item => item.label; + const inputProps = { + itemDisplayFn, + items, + groups, + searchTerm: "" + }; + + const component = renderer.create(); + expect(component).toMatchSnapshot(); + }); + + test("passes prop to group case several group items selected", () => { + const items = [{ id: 1, label: "first" }, { id: 2, label: "second" }]; + const groups = [{ id: 3, label: "Group 1", itemIds: [1, 2] }]; + const itemDisplayFn = item => item.label; + const inputProps = { + items, + groups, + itemDisplayFn, + searchTerm: "", + selected: [1] + }; + + const component = renderer.create(); + expect(component).toMatchSnapshot(); + }); + + test("passes prop to group case all group items selected", () => { + const items = [{ id: 1, label: "first" }, { id: 2, label: "second" }]; + const groups = [{ id: 3, label: "Group 1", itemIds: [1, 2] }]; + const itemDisplayFn = item => item.label; + const inputProps = { + itemDisplayFn, + items, + groups, + searchTerm: "", + selected: [1, 2] + }; + + const component = renderer.create(); + expect(component).toMatchSnapshot(); + }); + + test("handles onGroupLinkClick right", () => { + const inputItems = [{ id: 1, label: "first" }]; + const inputGroups = [{ id: 3, label: "Group 1", itemIds: [1] }]; + const itemDisplayFn = item => item.label; + + let isOnSelectGroupClickCalled = false; + const onSelectGroupClick = () => (isOnSelectGroupClickCalled = true); + + const listItems = mount( + + ); + + const group = listItems.find(ListGroup); + group.props().onGroupLinkClick(); + expect(isOnSelectGroupClickCalled).toBe(true); + }); +}); diff --git a/test/multiselection_list.spec.js b/test/multiselection_list.spec.js new file mode 100644 index 0000000..be6472a --- /dev/null +++ b/test/multiselection_list.spec.js @@ -0,0 +1,468 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; + +import MultiSelectionList, {DEFAULT_MS_DELAY_ONCHANGE_FOR_LAZY_LOADING} from '../../../src/components/multiselectionlist/multiselection_list'; +import ListNavigation from '../../../src/components/multiselectionlist/multiselection_navigation'; +import ListItems from '../../../src/components/multiselectionlist/multiselection_items'; +import Input from '../../../src/components/input/input_text'; +import styles from '../../../src/components/multiselectionlist/multiselection_list.scss'; +import {MOVE} from '../../../src/components/multiselectionlist/multiselection_list.constants'; + +describe('MultiSelectionList tests:', () => { + + test('contains style', () => { + const component = shallow(); + const cmp = component.find(`.${styles.multi_selection_list}`); + expect(cmp.length).toBe(1); + }); + + test('contains input filter', () => { + const component = shallow(); + const elm = component.find(Input); + expect(elm.length).toBe(1); + }); + + test('doesnt contain navigation by default', () => { + const component = shallow(); + const elm = component.find(ListNavigation); + expect(elm.length).toBe(0); + expect(component.props().className).not.toContain('navigation_list'); + }); + + it('renders list items by default', () => { + const component = shallow(); + + const listItems = component.find(ListItems); + expect(listItems.length).toBe(1); + }); + + it('passes right props to list items', () => { + const inputProps = { + groups: [{id: 1}], + filterResultsText: 'testFilterResultsText', + emptyText: 'testEmptyText' + }; + const component = shallow(); + + const listItems = component.find(ListItems); + expect(listItems.props().groups).toEqual(inputProps.groups); + expect(listItems.props().filterResultsText).toEqual(inputProps.filterResultsText); + expect(listItems.props().emptyText).toEqual(inputProps.emptyText); + }); + + it.skip('passes search term to list items', () => { + const searchTerm = 'testSearch'; + const component = shallow(); + + const listFilter = component.find(Input); + listFilter.props().onChange({target: {value: searchTerm}}); + + const listItems = component.find(ListItems); + expect(listItems.props().searchTerm).toEqual(searchTerm); + }); + + it('handles onSelectGroupClick right', () => { + const items = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + + const component = shallow(); + component.state().selected = [4]; + + const listItems = component.find(ListItems); + listItems.props().onSelectGroupClick([1, 3]); + + expect(component.state().selected.length).toBe(3); + expect(component.state().selected).toEqual([4, 1, 3]); + }); + + it('handles onDeselectGroupClick right', () => { + const items = [{id: 1}, {id: 2}, {id: 3}, {id: 4}]; + + const component = shallow(); + component.state().selected = [1, 4, 3]; + + const listItems = component.find(ListItems); + listItems.props().onDeselectGroupClick([1, 3]); + + expect(component.state().selected.length).toBe(1); + expect(component.state().selected).toEqual([4]); + }); + + it('handles moveItem UP right', () => { + const item1 = {id: 1}; + const item2 = {id: 2}; + const item3 = {id: 3}; + let isOnOrderChangedCalled = false; + + const component = shallow(isOnOrderChangedCalled=true} + />); + component.setState({selected: [2]}); + + const listNavigation = component.find(ListNavigation); + listNavigation.props().onNavigationClick(MOVE.UP)(); + + expect(isOnOrderChangedCalled).toBe(true); + expect(component.state().items.length).toBe(3); + expect(component.state().items[0].id).toBe(item2.id); + expect(component.state().items[1].id).toBe(item1.id); + expect(component.state().items[2].id).toBe(item3.id); + }); + + it('handles moveItem DOWN right', () => { + const item1 = {id: 1}; + const item2 = {id: 2}; + const item3 = {id: 3}; + let isOnOrderChangedCalled = false; + + const component = shallow(isOnOrderChangedCalled=true} + />); + component.setState({selected: [2]}); + + const listNavigation = component.find(ListNavigation); + listNavigation.props().onNavigationClick(MOVE.DOWN)(); + + expect(isOnOrderChangedCalled).toBe(true); + expect(component.state().items.length).toBe(3); + expect(component.state().items[0].id).toBe(item1.id); + expect(component.state().items[1].id).toBe(item3.id); + expect(component.state().items[2].id).toBe(item2.id); + }); + + it('marks locked item with specific class', () => { + const items = [{id: 1, locked: true}]; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + + const listItems = component.find('li'); + expect(listItems.length).toBe(1); + + const lockedItem = listItems.get(0); + expect(lockedItem.props.className.includes(styles.locked)).toBe(true); + }); + + it('disables up navigation for first after locked item', () => { + const lockedItem = {id: 1, locked: true}; + const simpleItem = {id: 2}; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + component.setState({selected: [simpleItem.id]}); + + const navigation = component.find(ListNavigation); + expect(navigation.props().disabledNavigation.up).toBe(true); + }); + + it('disables navigation when search term is entered', () => { + const lockedItem = {id: 1, locked: true}; + const simpleItem = {id: 2}; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + component.setState({selected: [simpleItem.id]}); + + const navigation = component.find(ListNavigation); + expect(navigation.props().disabledNavigation.up).toBe(true); + expect(navigation.props().disabledNavigation.down).toBe(true); + }); + + it('prevents only locked item from select', () => { + const inputLockedItem = {id: 1, locked: true}; + const inputSimpleItem = {id: 2}; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + + const listItems = component.find('li'); + expect(listItems.length).toBe(2); + + const lockedItem = listItems.get(0); + lockedItem.props.onClick({}); + expect(component.state().selected.length).toBe(0); + + const simpleItem = listItems.get(1); + simpleItem.props.onClick({}); + expect(component.state().selected.length).toBe(1); + expect(component.state().selected[0]).toBe(inputSimpleItem.id); + }); + + it('prevents only locked item from double-click', () => { + const inputLockedItem = {id: 1, locked: true}; + const inputSimpleItem = {id: 2}; + let isDoubleClicked = false; + const onDoubleClick = () => isDoubleClicked=true; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + + const listItems = component.find('li'); + expect(listItems.length).toBe(2); + + const lockedItem = listItems.get(0); + lockedItem.props.onDoubleClick(); + expect(isDoubleClicked).toBe(false); + + const simpleItem = listItems.get(1); + simpleItem.props.onDoubleClick(); + expect(isDoubleClicked).toBe(true); + }); + + it('prevents only locked item from select by dragging', () => { + const inputLockedItem = {id: 1, locked: true}; + const inputSimpleItem = {id: 2}; + const isItemLockedFn = (item) => item.locked; + + const component = mount(); + + const listItems = component.find('li'); + expect(listItems.length).toBe(2); + + const lockedItem = listItems.get(0); + lockedItem.props.onDragStart(); + expect(component.state().selected.length).toBe(0); + + const simpleItem = listItems.get(1); + simpleItem.props.onDragStart(); + expect(component.state().selected.length).toBe(1); + expect(component.state().selected[0]).toBe(inputSimpleItem.id); + }); + + it('handles dragSelectedItems case move up', () => { + const item1 = {id: 1}; + const item2 = {id: 2}; + const item3 = {id: 3}; + const item4 = {id: 4}; + const component = mount(); + + const dragToIndex = 4; + const listItems = component.find(ListItems); + component.state().selected = [item1.id, item3.id]; + listItems.props().dragSelectedItems(dragToIndex); + + const actualItems = component.state().items; + expect(actualItems.length).toBe(dragToIndex); + expect(actualItems[0].id).toBe(item2.id); + expect(actualItems[1].id).toBe(item4.id); + expect(actualItems[2].id).toBe(item1.id); + expect(actualItems[3].id).toBe(item3.id); + }); + + it('handles dragSelectedItems case move down', () => { + const item1 = {id: 1}; + const item2 = {id: 2}; + const item3 = {id: 3}; + const item4 = {id: 4}; + const component = mount(); + + const dragToIndex = 0; + const listItems = component.find(ListItems); + component.state().selected = [item2.id, item4.id]; + listItems.props().dragSelectedItems(dragToIndex); + + const actualItems = component.state().items; + expect(actualItems.length).toBe(4); + expect(actualItems[0].id).toBe(item2.id); + expect(actualItems[1].id).toBe(item4.id); + expect(actualItems[2].id).toBe(item1.id); + expect(actualItems[3].id).toBe(item3.id); + }); + + it('handles dragSelectedItems case move to center', () => { + const item1 = {id: 1}; + const item2 = {id: 2}; + const item3 = {id: 3}; + const item4 = {id: 4}; + const component = mount(); + + const dragToIndex = 2; + const listItems = component.find(ListItems); + component.state().selected = [item1.id, item4.id]; + listItems.props().dragSelectedItems(dragToIndex); + + const actualItems = component.state().items; + expect(actualItems.length).toBe(4); + expect(actualItems[0].id).toBe(item2.id); + expect(actualItems[1].id).toBe(item1.id); + expect(actualItems[2].id).toBe(item4.id); + expect(actualItems[3].id).toBe(item3.id); + }); + + test('doesnt sort items by default', () => { + const displayFn = (item) => item.label; + const items = [{id: 1, label: 'ztest'}, {id: 2, label: 'atest'}]; + + const multiselectionList = mount(); + const item = multiselectionList.find('li').get(0); + item.props.onClick({}); + + const actualItems = multiselectionList.state().items; + expect(actualItems.length).toBe(2); + expect(actualItems[0].label).toBe('ztest'); + expect(actualItems[1].label).toBe('atest'); + }); + + test('sorts items when configured', () => { + const displayFn = (item) => item.label; + const items = [{id: 1, label: 'ztest'}, {id: 2, label: 'atest'}]; + const sortFn = (items) => items.sort((a, b)=> { + if(a.labelb.label) return 1; + return 0; + }); + + const multiselectionList = mount(); + const item = multiselectionList.find('li').get(0); + item.props.onClick({}); + + const actualItems = multiselectionList.state().items; + expect(actualItems.length).toBe(2); + expect(actualItems[0].label).toBe('atest'); + expect(actualItems[1].label).toBe('ztest'); + }); + + test('doesnt change items order when selection is changed', () => { + const displayFn = (item) => item.label; + const items = [{id: 1, label: 'ztest'}, {id: 2, label: 'atest'}]; + const groups = [{id: 3, label: 'Group 1', selectGroupLabel: 'Select All', itemIds: [1, 2]}]; + const sortFn = (items) => items.sort((a, b)=> { + if(a.labelb.label) return 1; + return 0; + }); + + const multiselectionList = mount(); + const item = multiselectionList.find('li').get(1); + item.props.onClick({}); + + const actualItems = multiselectionList.state().items; + expect(actualItems.length).toBe(2); + expect(actualItems[0].label).toBe('ztest'); + expect(actualItems[1].label).toBe('atest'); + }); + + test('doesnt change grouped items order when selection is changed', () => { + const displayFn = (item) => item.label; + const items = [{id: 1, label: 'ztest'}, {id: 2, label: 'atest'}]; + const sortFn = (items) => items.sort((a, b)=> { + if(a.labelb.label) return 1; + return 0; + }); + + const multiselectionList = mount(); + const item = multiselectionList.find('li').get(1); + item.props.onClick({}); + + const actualItems = multiselectionList.state().items; + expect(actualItems.length).toBe(2); + expect(actualItems[0].label).toBe('atest'); + expect(actualItems[1].label).toBe('ztest'); + }); + + test('resets state on receiving new properties', () => { + const initialItems = [{id: 1, locked: false}, {id: 2, locked: true}]; + const selectedIds = [2]; + const isItemLockedFn = ({locked}) => locked; + const component = mount(); + + component.setState({selected: [2], lastSelectedIndex: 1}); + const items = [{id: 1, locked: false}, {id: 2, locked: false}]; + component.instance().componentWillReceiveProps({items, isItemLockedFn, selectedIds}); + + expect(component.state().items).toEqual(items); + expect(component.state().selected).toEqual(selectedIds); + expect(component.state().lastSelectedIndex).toEqual(null); + }); + + test('doesnt reset state when items not changed', () => { + const items = [{id: 1, locked: false}, {id: 2, locked: true}]; + const selectedIds = []; + const isItemLockedFn = ({locked}) => locked; + const component = mount(); + + component.setState({selected: [2], lastSelectedIndex: 1}); + component.instance().componentWillReceiveProps({items, isItemLockedFn, selectedIds}); + + expect(component.state().items).toEqual(items); + expect(component.state().selected).toEqual([2]); + expect(component.state().lastSelectedIndex).toEqual(1); + }); + + test('passes msDelayOnChange to input filter', () => { + const msDelayOnChangeFilter = 800; + const component = mount(); + + const input = component.find(Input); + expect(input.props().msDelayOnChange).toBe(msDelayOnChangeFilter); + }); + + test('passes msDelayOnChange and autoFocusForRerender to input filter when lazyLoad passed', () => { + const component = mount(); + + const input = component.find(Input); + expect(input.props().msDelayOnChange).toEqual(DEFAULT_MS_DELAY_ONCHANGE_FOR_LAZY_LOADING); + expect(input.props().autoFocusForRerender).toBe(true); + }); + + it('render component with selected items list', () => { + const items = [{id: 1},{id: 2},{id: 3}]; + const selectedItems = [1]; + + const component = shallow(); + expect(component.state().selected).toBe(selectedItems); + }); + + test('passes handleMultiSelect if have locked column', () => { + const items = [{id: 1, locked: true}, {id: 2, locked: false}, {id: 3, locked: false}, {id: 4, locked: false}]; + const isItemLockedFn = ({locked}) => locked; + const component = mount(); + + component.setState({selected: [2], lastSelectedIndex: 1}); + component.instance().handleMultiSelect(4); + + expect(component.state().selected).toEqual([2, 3, 4]); + }); + + it('render component with selected items list with selectAll option', () => { + const items = [1,2,3]; + const selectedIds = [1,2,3]; + const isItemLockedFn = ({locked}) => locked; + + const component = shallow(); + expect(component.state().selectedAll).toBe(true); + + }); +}); diff --git a/test/multiselection_list_utils.spec.js b/test/multiselection_list_utils.spec.js new file mode 100644 index 0000000..3288f88 --- /dev/null +++ b/test/multiselection_list_utils.spec.js @@ -0,0 +1,60 @@ +import { + isAllSelected, + PARTIAL +} from "../../../src/components/multiselectionlist/multiselection_list_utils"; + +describe("multiselection_list_utils isAllSelected tests:", () => { + test("both undefined", () => { + const result = isAllSelected(undefined, undefined); + + expect(result).toBe(false); + }); + + test("both empty", () => { + const result = isAllSelected([], []); + + expect(result).toBe(false); + }); + + test("partial for ids", () => { + const result = isAllSelected([1], [1, 2, 3]); + + expect(result).toBe(PARTIAL); + }); + + test("false for ids", () => { + const result = isAllSelected([], [1, 2, 3]); + + expect(result).toBe(false); + }); + + test("true for ids", () => { + const result = isAllSelected([3, 2, 1], [1, 2, 3]); + + expect(result).toBe(true); + }); + + test("partial for objects", () => { + const result = isAllSelected( + [{ id: 2 }], + [{ id: 1 }, { id: 2 }, { id: 3 }] + ); + + expect(result).toBe(PARTIAL); + }); + + test("false for objects", () => { + const result = isAllSelected([], [{ id: 1 }, { id: 2 }, { id: 3 }]); + + expect(result).toBe(false); + }); + + test("true for objects", () => { + const result = isAllSelected( + [{ id: 2 }, { id: 3 }, { id: 1 }], + [{ id: 1 }, { id: 2 }, { id: 3 }] + ); + + expect(result).toBe(true); + }); +});