diff --git a/.babelrc.js b/.babelrc.js index 1c1b8889..c9878103 100755 --- a/.babelrc.js +++ b/.babelrc.js @@ -7,6 +7,7 @@ module.exports = { }, ], '@babel/preset-react', + '@babel/preset-typescript', ], env: { test: { diff --git a/.prettierrc b/.prettierrc index 0ebf6b9e..7615c2b9 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,12 +1,6 @@ { - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "semi": true, "singleQuote": true, "trailingComma": "es5", - "bracketSpacing": true, - "jsxBracketSameLine": false, "overrides": [ { "files": ".prettierrc", diff --git a/package.json b/package.json index bdb4eb9c..efbb9600 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.7.1", "description": "Drag-and-drop sortable component for nested data and hierarchies", "scripts": { - "start": "parcel website/index.html", + "start": "./start.sh", "prebuild": "yarn run lint && yarn run clean", "build": "rollup -c", "buildOnly": "rollup -c", @@ -13,7 +13,7 @@ "clean:storybook": "rimraf build/storybook", "clean:website": "rimraf build", "lint": "eslint src website", - "prettier": "prettier --write \"{src,example/src,stories}/**/*.{js,css,md}\"", + "prettier": "prettier --write \"{src,website,stories}/**/*.{js,jsx,ts,tsx,css,md}\"", "prepublishOnly": "yarn run test && yarn run build", "release": "standard-version", "test": "jest", @@ -34,7 +34,8 @@ "homepage": "https://frontend-collective.github.io/react-sortable-tree/", "bugs": "https://github.com/frontend-collective/react-sortable-tree/issues", "authors": [ - "Chris Fritz" + "Fritz", + "Wei Wei Wu" ], "license": "MIT", "jest": { @@ -48,6 +49,8 @@ "moduleFileExtensions": [ "js", "jsx", + "ts", + "tsx", "json" ], "moduleDirectories": [ @@ -69,64 +72,65 @@ "> 1%" ], "dependencies": { - "frontend-collective-react-dnd-scrollzone": "^1.0.2", "lodash.isequal": "^4.5.0", + "memoize-one": "^5.1.1", "prop-types": "^15.6.1", - "react-dnd": "^9.4.0", "react-dnd-html5-backend": "^10.0.2", - "react-lifecycles-compat": "^3.0.4", + "react-dnd-scrollzone": "^5.0.0", "react-virtualized": "^9.21.2" }, "peerDependencies": { - "react": "^16.3.0", - "react-dnd": "^7.3.0", - "react-dom": "^16.3.0" + "react": "^16.11.0", + "react-dnd": "^10.0.2", + "react-dom": "^16.11.0" }, "devDependencies": { - "@babel/cli": "^7.7.0", - "@babel/core": "^7.7.2", + "@babel/cli": "^7.8.4", + "@babel/core": "^7.8.4", "@babel/plugin-transform-modules-commonjs": "^7.1.0", - "@babel/preset-env": "^7.7.1", - "@babel/preset-react": "^7.7.0", - "@storybook/addon-options": "^5.2.6", - "@storybook/addon-storyshots": "^5.2.6", - "@storybook/react": "^5.2.6", + "@babel/preset-env": "^7.8.4", + "@babel/preset-react": "^7.8.3", + "@babel/preset-typescript": "^7.8.3", + "@rollup/plugin-commonjs": "^11.0.2", + "@rollup/plugin-node-resolve": "^7.1.1", + "@storybook/addon-options": "^5.3.13", + "@storybook/addon-storyshots": "^5.3.13", + "@storybook/react": "^5.3.13", + "@types/react": "^16.9.19", "autoprefixer": "^9.7.1", - "babel-core": "^7.0.0-bridge.0", - "babel-eslint": "^10.0.3", - "babel-jest": "^24.9.0", - "babel-loader": "^8.0.4", - "codesandbox": "~2.1.10", + "babel-loader": "8", + "codesandbox": "~2.1.12", "coveralls": "^3.0.1", - "cross-env": "^6.0.3", - "enzyme": "^3.10.0", + "cross-env": "^7.0.0", + "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.14.0", - "eslint": "^6.6.0", + "eslint": "^6.8.0", "eslint-config-airbnb": "^18.0.1", - "eslint-config-prettier": "^6.5.0", - "eslint-plugin-import": "^2.18.2", + "eslint-config-prettier": "^6.10.0", + "eslint-plugin-import": "^2.20.1", "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-react": "^7.16.0", - "gh-pages": "^2.1.1", - "jest": "^24.9.0", + "eslint-plugin-react": "^7.18.3", + "eslint-plugin-react-hooks": "^1.7.0", + "gh-pages": "^2.2.0", + "jest": "^25.1.0", "jest-enzyme": "^7.1.2", "parcel-bundler": "^1.12.4", "prettier": "^1.19.1", "react": "^16.11.0", "react-addons-shallow-compare": "^15.6.2", + "react-dnd": "^10.0.2", "react-dnd-test-backend": "^10.0.2", - "react-dnd-touch-backend": "^9.4.0", + "react-dnd-touch-backend": "^10.0.2", "react-dom": "^16.11.0", "react-hot-loader": "^4.12.17", "react-sortable-tree-theme-file-explorer": "^2.0.0", "react-test-renderer": "^16.11.0", - "rimraf": "^3.0.0", - "rollup": "^1.27.0", + "rimraf": "^3.0.2", + "rollup": "^1.31.1", "rollup-plugin-babel": "^4.0.3", - "rollup-plugin-commonjs": "^10.1.0", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-postcss": "^2.0.3", - "standard-version": "^7.0.0" + "rollup-plugin-postcss": "^2.0.6", + "standard-version": "^7.1.0", + "typescript": "^3.7.5" }, "keywords": [ "react", diff --git a/src/__snapshots__/react-sortable-tree.test.js.snap b/src/__snapshots__/react-sortable-tree.test.js.snap deleted file mode 100644 index e8454cbe..00000000 --- a/src/__snapshots__/react-sortable-tree.test.js.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should render tree correctly 1`] = ` -
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-`; diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/src/utils/classnames.js b/src/legacy/classnames.js similarity index 100% rename from src/utils/classnames.js rename to src/legacy/classnames.js diff --git a/src/utils/dnd-manager.js b/src/legacy/dnd-manager.js similarity index 100% rename from src/utils/dnd-manager.js rename to src/legacy/dnd-manager.js diff --git a/src/node-renderer-default.css b/src/legacy/node-renderer-default.css similarity index 100% rename from src/node-renderer-default.css rename to src/legacy/node-renderer-default.css diff --git a/src/node-renderer-default.js b/src/legacy/node-renderer-default.js similarity index 100% rename from src/node-renderer-default.js rename to src/legacy/node-renderer-default.js diff --git a/src/placeholder-renderer-default.css b/src/legacy/placeholder-renderer-default.css similarity index 100% rename from src/placeholder-renderer-default.css rename to src/legacy/placeholder-renderer-default.css diff --git a/src/placeholder-renderer-default.js b/src/legacy/placeholder-renderer-default.js similarity index 100% rename from src/placeholder-renderer-default.js rename to src/legacy/placeholder-renderer-default.js diff --git a/src/react-sortable-tree.css b/src/legacy/react-sortable-tree.css similarity index 100% rename from src/react-sortable-tree.css rename to src/legacy/react-sortable-tree.css diff --git a/src/react-sortable-tree.js b/src/legacy/react-sortable-tree.js similarity index 95% rename from src/react-sortable-tree.js rename to src/legacy/react-sortable-tree.js index b83e16a0..5839c8b1 100644 --- a/src/react-sortable-tree.js +++ b/src/legacy/react-sortable-tree.js @@ -2,14 +2,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { AutoSizer, List } from 'react-virtualized'; import isEqual from 'lodash.isequal'; -import withScrolling, { - createScrollingComponent, - createVerticalStrength, - createHorizontalStrength, -} from 'frontend-collective-react-dnd-scrollzone'; import { DndProvider, DndContext } from 'react-dnd'; import HTML5Backend from 'react-dnd-html5-backend'; -import { polyfill } from 'react-lifecycles-compat'; import 'react-virtualized/styles.css'; import TreeNode from './tree-node'; import NodeRendererDefault from './node-renderer-default'; @@ -82,7 +76,6 @@ class ReactSortableTree extends Component { nodeContentRenderer, treeNodeRenderer, isVirtualized, - slideRegionSize, } = mergeTheme(props); this.dndManager = new DndManager(this); @@ -99,11 +92,7 @@ class ReactSortableTree extends Component { // Prepare scroll-on-drag options for this list if (isVirtualized) { - this.scrollZoneVirtualList = (createScrollingComponent || withScrolling)( - List - ); - this.vStrength = createVerticalStrength(slideRegionSize); - this.hStrength = createHorizontalStrength(slideRegionSize); + this.scrollZoneVirtualList = List; } this.state = { @@ -130,7 +119,7 @@ class ReactSortableTree extends Component { this.dragHover = this.dragHover.bind(this); this.endDrag = this.endDrag.bind(this); this.drop = this.drop.bind(this); - this.handleDndMonitorChange = this.handleDndMonitorChange.bind(this); + // this.handleDndMonitorChange = this.handleDndMonitorChange.bind(this); } componentDidMount() { @@ -144,12 +133,12 @@ class ReactSortableTree extends Component { ); this.setState(stateUpdate); - // Hook into react-dnd state changes to detect when the drag ends - // TODO: This is very brittle, so it needs to be replaced if react-dnd - // offers a more official way to detect when a drag ends - this.clearMonitorSubscription = this.props.dragDropManager - .getMonitor() - .subscribeToStateChange(this.handleDndMonitorChange); + // // Hook into react-dnd state changes to detect when the drag ends + // // TODO: This is very brittle, so it needs to be replaced if react-dnd + // // offers a more official way to detect when a drag ends + // this.clearMonitorSubscription = this.props.dragDropManager + // .getMonitor() + // .subscribeToStateChange(this.handleDndMonitorChange); } static getDerivedStateFromProps(nextProps, prevState) { @@ -212,9 +201,9 @@ class ReactSortableTree extends Component { } } - componentWillUnmount() { - this.clearMonitorSubscription(); - } + // componentWillUnmount() { + // this.clearMonitorSubscription(); + // } getRows(treeData) { return memoizedGetFlatDataFromTree({ @@ -684,23 +673,22 @@ class ReactSortableTree extends Component { } else if (isVirtualized) { containerStyle = { height: '100%', ...containerStyle }; - const ScrollZoneVirtualList = this.scrollZoneVirtualList; // Render list with react-virtualized list = ( {({ height, width }) => ( - { - this.scrollTop = scrollTop; - }} + // onScroll={({ scrollTop }) => { + // this.scrollTop = scrollTop; + // }} height={height} style={innerStyle} rowCount={rows.length} @@ -936,8 +924,6 @@ ReactSortableTree.defaultProps = { rowDirection: 'ltr', }; -polyfill(ReactSortableTree); - const SortableTreeWithoutDndContext = props => ( {({ dragDropManager }) => @@ -950,9 +936,9 @@ const SortableTreeWithoutDndContext = props => ( const SortableTree = props => ( - + -) +); // Export the tree component without the react-dnd DragDropContext, // for when component is used with other components using react-dnd. diff --git a/src/react-sortable-tree.test.js b/src/legacy/react-sortable-tree.test.js similarity index 100% rename from src/react-sortable-tree.test.js rename to src/legacy/react-sortable-tree.test.js diff --git a/src/tests.js b/src/legacy/tests.js similarity index 100% rename from src/tests.js rename to src/legacy/tests.js diff --git a/src/tree-node.css b/src/legacy/tree-node.css similarity index 100% rename from src/tree-node.css rename to src/legacy/tree-node.css diff --git a/src/legacy/tree-node.js b/src/legacy/tree-node.js new file mode 100644 index 00000000..73823b86 --- /dev/null +++ b/src/legacy/tree-node.js @@ -0,0 +1,196 @@ +import React, { Children, cloneElement } from 'react'; +import PropTypes from 'prop-types'; +import classnames from './utils/classnames'; +import './tree-node.css'; + +const TreeNode = ({ + children, + listIndex, + swapFrom, + swapLength, + swapDepth, + scaffoldBlockPxWidth, + lowerSiblingCounts, + connectDropTarget, + isOver, + draggedNode, + canDrop, + treeIndex, + treeId, // Delete from otherProps + getPrevRow, // Delete from otherProps + node, // Delete from otherProps + path, // Delete from otherProps + rowDirection, + ...otherProps +}) => { + const rowDirectionClass = rowDirection === 'rtl' ? 'rst__rtl' : null; + + // Construct the scaffold representing the structure of the tree + const scaffoldBlockCount = lowerSiblingCounts.length; + const scaffold = []; + lowerSiblingCounts.forEach((lowerSiblingCount, i) => { + let lineClass = ''; + if (lowerSiblingCount > 0) { + // At this level in the tree, the nodes had sibling nodes further down + + if (listIndex === 0) { + // Top-left corner of the tree + // +-----+ + // | | + // | +--+ + // | | | + // +--+--+ + lineClass = 'rst__lineHalfHorizontalRight rst__lineHalfVerticalBottom'; + } else if (i === scaffoldBlockCount - 1) { + // Last scaffold block in the row, right before the row content + // +--+--+ + // | | | + // | +--+ + // | | | + // +--+--+ + lineClass = 'rst__lineHalfHorizontalRight rst__lineFullVertical'; + } else { + // Simply connecting the line extending down to the next sibling on this level + // +--+--+ + // | | | + // | | | + // | | | + // +--+--+ + lineClass = 'rst__lineFullVertical'; + } + } else if (listIndex === 0) { + // Top-left corner of the tree, but has no siblings + // +-----+ + // | | + // | +--+ + // | | + // +-----+ + lineClass = 'rst__lineHalfHorizontalRight'; + } else if (i === scaffoldBlockCount - 1) { + // The last or only node in this level of the tree + // +--+--+ + // | | | + // | +--+ + // | | + // +-----+ + lineClass = 'rst__lineHalfVerticalTop rst__lineHalfHorizontalRight'; + } + + scaffold.push( +
+ ); + + if (treeIndex !== listIndex && i === swapDepth) { + // This row has been shifted, and is at the depth of + // the line pointing to the new destination + let highlightLineClass = ''; + + if (listIndex === swapFrom + swapLength - 1) { + // This block is on the bottom (target) line + // This block points at the target block (where the row will go when released) + highlightLineClass = 'rst__highlightBottomLeftCorner'; + } else if (treeIndex === swapFrom) { + // This block is on the top (source) line + highlightLineClass = 'rst__highlightTopLeftCorner'; + } else { + // This block is between the bottom and top + highlightLineClass = 'rst__highlightLineVertical'; + } + + let style; + if (rowDirection === 'rtl') { + style = { + width: scaffoldBlockPxWidth, + right: scaffoldBlockPxWidth * i, + }; + } else { + // Default ltr + style = { + width: scaffoldBlockPxWidth, + left: scaffoldBlockPxWidth * i, + }; + } + + scaffold.push( +
+ ); + } + }); + + let style; + if (rowDirection === 'rtl') { + style = { right: scaffoldBlockPxWidth * scaffoldBlockCount }; + } else { + // Default ltr + style = { left: scaffoldBlockPxWidth * scaffoldBlockCount }; + } + + return connectDropTarget( +
+ {scaffold} + +
+ {Children.map(children, child => + cloneElement(child, { + isOver, + canDrop, + draggedNode, + }) + )} +
+
+ ); +}; + +TreeNode.defaultProps = { + swapFrom: null, + swapDepth: null, + swapLength: null, + canDrop: false, + draggedNode: null, + rowDirection: 'ltr', +}; + +TreeNode.propTypes = { + treeIndex: PropTypes.number.isRequired, + treeId: PropTypes.string.isRequired, + swapFrom: PropTypes.number, + swapDepth: PropTypes.number, + swapLength: PropTypes.number, + scaffoldBlockPxWidth: PropTypes.number.isRequired, + lowerSiblingCounts: PropTypes.arrayOf(PropTypes.number).isRequired, + + listIndex: PropTypes.number.isRequired, + children: PropTypes.node.isRequired, + + // Drop target + connectDropTarget: PropTypes.func.isRequired, + isOver: PropTypes.bool.isRequired, + canDrop: PropTypes.bool, + draggedNode: PropTypes.shape({}), + + // used in dndManager + getPrevRow: PropTypes.func.isRequired, + node: PropTypes.shape({}).isRequired, + path: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ).isRequired, + + // rtl support + rowDirection: PropTypes.string, +}; + +export default TreeNode; diff --git a/src/tree-placeholder.js b/src/legacy/tree-placeholder.js similarity index 100% rename from src/tree-placeholder.js rename to src/legacy/tree-placeholder.js diff --git a/src/tree-node.js b/src/tree-node.js deleted file mode 100644 index 11c9f29a..00000000 --- a/src/tree-node.js +++ /dev/null @@ -1,204 +0,0 @@ -import React, { Component, Children, cloneElement } from 'react'; -import PropTypes from 'prop-types'; -import classnames from './utils/classnames'; -import './tree-node.css'; - -class TreeNode extends Component { - render() { - const { - children, - listIndex, - swapFrom, - swapLength, - swapDepth, - scaffoldBlockPxWidth, - lowerSiblingCounts, - connectDropTarget, - isOver, - draggedNode, - canDrop, - treeIndex, - treeId, // Delete from otherProps - getPrevRow, // Delete from otherProps - node, // Delete from otherProps - path, // Delete from otherProps - rowDirection, - ...otherProps - } = this.props; - - const rowDirectionClass = rowDirection === 'rtl' ? 'rst__rtl' : null; - - // Construct the scaffold representing the structure of the tree - const scaffoldBlockCount = lowerSiblingCounts.length; - const scaffold = []; - lowerSiblingCounts.forEach((lowerSiblingCount, i) => { - let lineClass = ''; - if (lowerSiblingCount > 0) { - // At this level in the tree, the nodes had sibling nodes further down - - if (listIndex === 0) { - // Top-left corner of the tree - // +-----+ - // | | - // | +--+ - // | | | - // +--+--+ - lineClass = - 'rst__lineHalfHorizontalRight rst__lineHalfVerticalBottom'; - } else if (i === scaffoldBlockCount - 1) { - // Last scaffold block in the row, right before the row content - // +--+--+ - // | | | - // | +--+ - // | | | - // +--+--+ - lineClass = 'rst__lineHalfHorizontalRight rst__lineFullVertical'; - } else { - // Simply connecting the line extending down to the next sibling on this level - // +--+--+ - // | | | - // | | | - // | | | - // +--+--+ - lineClass = 'rst__lineFullVertical'; - } - } else if (listIndex === 0) { - // Top-left corner of the tree, but has no siblings - // +-----+ - // | | - // | +--+ - // | | - // +-----+ - lineClass = 'rst__lineHalfHorizontalRight'; - } else if (i === scaffoldBlockCount - 1) { - // The last or only node in this level of the tree - // +--+--+ - // | | | - // | +--+ - // | | - // +-----+ - lineClass = 'rst__lineHalfVerticalTop rst__lineHalfHorizontalRight'; - } - - scaffold.push( -
- ); - - if (treeIndex !== listIndex && i === swapDepth) { - // This row has been shifted, and is at the depth of - // the line pointing to the new destination - let highlightLineClass = ''; - - if (listIndex === swapFrom + swapLength - 1) { - // This block is on the bottom (target) line - // This block points at the target block (where the row will go when released) - highlightLineClass = 'rst__highlightBottomLeftCorner'; - } else if (treeIndex === swapFrom) { - // This block is on the top (source) line - highlightLineClass = 'rst__highlightTopLeftCorner'; - } else { - // This block is between the bottom and top - highlightLineClass = 'rst__highlightLineVertical'; - } - - let style; - if (rowDirection === 'rtl') { - style = { - width: scaffoldBlockPxWidth, - right: scaffoldBlockPxWidth * i, - }; - } else { - // Default ltr - style = { - width: scaffoldBlockPxWidth, - left: scaffoldBlockPxWidth * i, - }; - } - - scaffold.push( -
- ); - } - }); - - let style; - if (rowDirection === 'rtl') { - style = { right: scaffoldBlockPxWidth * scaffoldBlockCount }; - } else { - // Default ltr - style = { left: scaffoldBlockPxWidth * scaffoldBlockCount }; - } - - return connectDropTarget( -
- {scaffold} - -
- {Children.map(children, child => - cloneElement(child, { - isOver, - canDrop, - draggedNode, - }) - )} -
-
- ); - } -} - -TreeNode.defaultProps = { - swapFrom: null, - swapDepth: null, - swapLength: null, - canDrop: false, - draggedNode: null, - rowDirection: 'ltr', -}; - -TreeNode.propTypes = { - treeIndex: PropTypes.number.isRequired, - treeId: PropTypes.string.isRequired, - swapFrom: PropTypes.number, - swapDepth: PropTypes.number, - swapLength: PropTypes.number, - scaffoldBlockPxWidth: PropTypes.number.isRequired, - lowerSiblingCounts: PropTypes.arrayOf(PropTypes.number).isRequired, - - listIndex: PropTypes.number.isRequired, - children: PropTypes.node.isRequired, - - // Drop target - connectDropTarget: PropTypes.func.isRequired, - isOver: PropTypes.bool.isRequired, - canDrop: PropTypes.bool, - draggedNode: PropTypes.shape({}), - - // used in dndManager - getPrevRow: PropTypes.func.isRequired, - node: PropTypes.shape({}).isRequired, - path: PropTypes.arrayOf( - PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - ).isRequired, - - // rtl support - rowDirection: PropTypes.string, -}; - -export default TreeNode; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..4895568d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +export type Path = string[]; +export type TreeNode = { + nodeId: string; + children?: TreeNode[] | Function; + expanded?: boolean; +}; +export type TreeData = TreeNode[]; diff --git a/src/utils/default-handlers.js b/src/utils/default-handlers.js deleted file mode 100644 index faf15fa9..00000000 --- a/src/utils/default-handlers.js +++ /dev/null @@ -1,53 +0,0 @@ -export function defaultGetNodeKey({ treeIndex }) { - return treeIndex; -} - -// Cheap hack to get the text of a react object -function getReactElementText(parent) { - if (typeof parent === 'string') { - return parent; - } - - if ( - parent === null || - typeof parent !== 'object' || - !parent.props || - !parent.props.children || - (typeof parent.props.children !== 'string' && - typeof parent.props.children !== 'object') - ) { - return ''; - } - - if (typeof parent.props.children === 'string') { - return parent.props.children; - } - - return parent.props.children - .map(child => getReactElementText(child)) - .join(''); -} - -// Search for a query string inside a node property -function stringSearch(key, searchQuery, node, path, treeIndex) { - if (typeof node[key] === 'function') { - // Search within text after calling its function to generate the text - return ( - String(node[key]({ node, path, treeIndex })).indexOf(searchQuery) > -1 - ); - } - if (typeof node[key] === 'object') { - // Search within text inside react elements - return getReactElementText(node[key]).indexOf(searchQuery) > -1; - } - - // Search within string - return node[key] && String(node[key]).indexOf(searchQuery) > -1; -} - -export function defaultSearchMethod({ node, path, treeIndex, searchQuery }) { - return ( - stringSearch('title', searchQuery, node, path, treeIndex) || - stringSearch('subtitle', searchQuery, node, path, treeIndex) - ); -} diff --git a/src/utils/generic-utils.test.js b/src/utils/generic-utils.spec.ts similarity index 100% rename from src/utils/generic-utils.test.js rename to src/utils/generic-utils.spec.ts diff --git a/src/utils/generic-utils.js b/src/utils/generic-utils.ts similarity index 68% rename from src/utils/generic-utils.js rename to src/utils/generic-utils.ts index b7424565..8de28eb9 100644 --- a/src/utils/generic-utils.js +++ b/src/utils/generic-utils.ts @@ -1,6 +1,9 @@ -/* eslint-disable import/prefer-default-export */ - -export function slideRows(rows, fromIndex, toIndex, count = 1) { +export function slideRows( + rows: T[], + fromIndex: number, + toIndex: number, + count = 1 +): T[] { const rowsWithoutMoved = [ ...rows.slice(0, fromIndex), ...rows.slice(fromIndex + count), diff --git a/src/utils/memoized-tree-data-utils.js b/src/utils/memoized-tree-data-utils.js deleted file mode 100644 index 8ab555ce..00000000 --- a/src/utils/memoized-tree-data-utils.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - insertNode, - getDescendantCount, - getFlatDataFromTree, -} from './tree-data-utils'; - -const memoize = f => { - let savedArgsArray = []; - let savedKeysArray = []; - let savedResult = null; - - return args => { - const keysArray = Object.keys(args).sort(); - const argsArray = keysArray.map(key => args[key]); - - // If the arguments for the last insert operation are different than this time, - // recalculate the result - if ( - argsArray.length !== savedArgsArray.length || - argsArray.some((arg, index) => arg !== savedArgsArray[index]) || - keysArray.some((key, index) => key !== savedKeysArray[index]) - ) { - savedArgsArray = argsArray; - savedKeysArray = keysArray; - savedResult = f(args); - } - - return savedResult; - }; -}; - -export const memoizedInsertNode = memoize(insertNode); -export const memoizedGetFlatDataFromTree = memoize(getFlatDataFromTree); -export const memoizedGetDescendantCount = memoize(getDescendantCount); diff --git a/src/utils/memoized-tree-data-utils.spec.ts b/src/utils/memoized-tree-data-utils.spec.ts new file mode 100644 index 00000000..dc63e430 --- /dev/null +++ b/src/utils/memoized-tree-data-utils.spec.ts @@ -0,0 +1,24 @@ +import { insertNode } from './tree-data-utils'; +import { memoizedInsertNode } from './memoized-tree-data-utils'; + +describe('insertNode', () => { + it('should handle empty data', () => { + type InsertParams = Parameters; + const params: InsertParams = [[], { nodeId: 'a' }, 0, 0]; + + let firstCall = insertNode(...params); + let secondCall = insertNode(...params); + expect(firstCall === secondCall).toEqual(false); + + firstCall = memoizedInsertNode(...params); + secondCall = memoizedInsertNode(...params); + expect(firstCall === secondCall).toEqual(true); + + expect( + memoizedInsertNode(...params) === + memoizedInsertNode( + ...([[{ nodeId: 'a' }], ...params.slice(1)] as InsertParams) + ) + ).toEqual(false); + }); +}); diff --git a/src/utils/memoized-tree-data-utils.test.js b/src/utils/memoized-tree-data-utils.test.js deleted file mode 100644 index e0e3afd4..00000000 --- a/src/utils/memoized-tree-data-utils.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { insertNode } from './tree-data-utils'; - -import { memoizedInsertNode } from './memoized-tree-data-utils'; - -describe('insertNode', () => { - it('should handle empty data', () => { - const params = { - treeData: [], - depth: 0, - minimumTreeIndex: 0, - newNode: {}, - getNodeKey: ({ treeIndex }) => treeIndex, - }; - - let firstCall = insertNode(params); - let secondCall = insertNode(params); - expect(firstCall === secondCall).toEqual(false); - - firstCall = memoizedInsertNode(params); - secondCall = memoizedInsertNode(params); - expect(firstCall === secondCall).toEqual(true); - - expect( - memoizedInsertNode(params) === - memoizedInsertNode({ ...params, treeData: [{}] }) - ).toEqual(false); - }); -}); diff --git a/src/utils/memoized-tree-data-utils.ts b/src/utils/memoized-tree-data-utils.ts new file mode 100644 index 00000000..c3695e3e --- /dev/null +++ b/src/utils/memoized-tree-data-utils.ts @@ -0,0 +1,10 @@ +import memoize from 'memoize-one'; +import { + insertNode, + getDescendantCount, + getFlatDataFromTree, +} from './tree-data-utils'; + +export const memoizedInsertNode = memoize(insertNode); +export const memoizedGetFlatDataFromTree = memoize(getFlatDataFromTree); +export const memoizedGetDescendantCount = memoize(getDescendantCount); diff --git a/src/utils/tree-data-utils.js b/src/utils/tree-data-utils.js deleted file mode 100644 index 09cb4dd5..00000000 --- a/src/utils/tree-data-utils.js +++ /dev/null @@ -1,1205 +0,0 @@ -/** - * Performs a depth-first traversal over all of the node descendants, - * incrementing currentIndex by 1 for each - */ -function getNodeDataAtTreeIndexOrNextIndex({ - targetIndex, - node, - currentIndex, - getNodeKey, - path = [], - lowerSiblingCounts = [], - ignoreCollapsed = true, - isPseudoRoot = false, -}) { - // The pseudo-root is not considered in the path - const selfPath = !isPseudoRoot - ? [...path, getNodeKey({ node, treeIndex: currentIndex })] - : []; - - // Return target node when found - if (currentIndex === targetIndex) { - return { - node, - lowerSiblingCounts, - path: selfPath, - }; - } - - // Add one and continue for nodes with no children or hidden children - if (!node.children || (ignoreCollapsed && node.expanded !== true)) { - return { nextIndex: currentIndex + 1 }; - } - - // Iterate over each child and their descendants and return the - // target node if childIndex reaches the targetIndex - let childIndex = currentIndex + 1; - const childCount = node.children.length; - for (let i = 0; i < childCount; i += 1) { - const result = getNodeDataAtTreeIndexOrNextIndex({ - ignoreCollapsed, - getNodeKey, - targetIndex, - node: node.children[i], - currentIndex: childIndex, - lowerSiblingCounts: [...lowerSiblingCounts, childCount - i - 1], - path: selfPath, - }); - - if (result.node) { - return result; - } - - childIndex = result.nextIndex; - } - - // If the target node is not found, return the farthest traversed index - return { nextIndex: childIndex }; -} - -export function getDescendantCount({ node, ignoreCollapsed = true }) { - return ( - getNodeDataAtTreeIndexOrNextIndex({ - getNodeKey: () => {}, - ignoreCollapsed, - node, - currentIndex: 0, - targetIndex: -1, - }).nextIndex - 1 - ); -} - -/** - * Walk all descendants of the given node, depth-first - * - * @param {Object} args - Function parameters - * @param {function} args.callback - Function to call on each node - * @param {function} args.getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean} args.ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * @param {boolean=} args.isPseudoRoot - If true, this node has no real data, and only serves - * as the parent of all the nodes in the tree - * @param {Object} args.node - A tree node - * @param {Object=} args.parentNode - The parent node of `node` - * @param {number} args.currentIndex - The treeIndex of `node` - * @param {number[]|string[]} args.path - Array of keys leading up to node to be changed - * @param {number[]} args.lowerSiblingCounts - An array containing the count of siblings beneath the - * previous nodes in this path - * - * @return {number|false} nextIndex - Index of the next sibling of `node`, - * or false if the walk should be terminated - */ -function walkDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - isPseudoRoot = false, - node, - parentNode = null, - currentIndex, - path = [], - lowerSiblingCounts = [], -}) { - // The pseudo-root is not considered in the path - const selfPath = isPseudoRoot - ? [] - : [...path, getNodeKey({ node, treeIndex: currentIndex })]; - const selfInfo = isPseudoRoot - ? null - : { - node, - parentNode, - path: selfPath, - lowerSiblingCounts, - treeIndex: currentIndex, - }; - - if (!isPseudoRoot) { - const callbackResult = callback(selfInfo); - - // Cut walk short if the callback returned false - if (callbackResult === false) { - return false; - } - } - - // Return self on nodes with no children or hidden children - if ( - !node.children || - (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) - ) { - return currentIndex; - } - - // Get all descendants - let childIndex = currentIndex; - const childCount = node.children.length; - if (typeof node.children !== 'function') { - for (let i = 0; i < childCount; i += 1) { - childIndex = walkDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - node: node.children[i], - parentNode: isPseudoRoot ? null : node, - currentIndex: childIndex + 1, - lowerSiblingCounts: [...lowerSiblingCounts, childCount - i - 1], - path: selfPath, - }); - - // Cut walk short if the callback returned false - if (childIndex === false) { - return false; - } - } - } - - return childIndex; -} - -/** - * Perform a change on the given node and all its descendants, traversing the tree depth-first - * - * @param {Object} args - Function parameters - * @param {function} args.callback - Function to call on each node - * @param {function} args.getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean} args.ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * @param {boolean=} args.isPseudoRoot - If true, this node has no real data, and only serves - * as the parent of all the nodes in the tree - * @param {Object} args.node - A tree node - * @param {Object=} args.parentNode - The parent node of `node` - * @param {number} args.currentIndex - The treeIndex of `node` - * @param {number[]|string[]} args.path - Array of keys leading up to node to be changed - * @param {number[]} args.lowerSiblingCounts - An array containing the count of siblings beneath the - * previous nodes in this path - * - * @return {number|false} nextIndex - Index of the next sibling of `node`, - * or false if the walk should be terminated - */ -function mapDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - isPseudoRoot = false, - node, - parentNode = null, - currentIndex, - path = [], - lowerSiblingCounts = [], -}) { - const nextNode = { ...node }; - - // The pseudo-root is not considered in the path - const selfPath = isPseudoRoot - ? [] - : [...path, getNodeKey({ node: nextNode, treeIndex: currentIndex })]; - const selfInfo = { - node: nextNode, - parentNode, - path: selfPath, - lowerSiblingCounts, - treeIndex: currentIndex, - }; - - // Return self on nodes with no children or hidden children - if ( - !nextNode.children || - (nextNode.expanded !== true && ignoreCollapsed && !isPseudoRoot) - ) { - return { - treeIndex: currentIndex, - node: callback(selfInfo), - }; - } - - // Get all descendants - let childIndex = currentIndex; - const childCount = nextNode.children.length; - if (typeof nextNode.children !== 'function') { - nextNode.children = nextNode.children.map((child, i) => { - const mapResult = mapDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - node: child, - parentNode: isPseudoRoot ? null : nextNode, - currentIndex: childIndex + 1, - lowerSiblingCounts: [...lowerSiblingCounts, childCount - i - 1], - path: selfPath, - }); - childIndex = mapResult.treeIndex; - - return mapResult.node; - }); - } - - return { - node: callback(selfInfo), - treeIndex: childIndex, - }; -} - -/** - * Count all the visible (expanded) descendants in the tree data. - * - * @param {!Object[]} treeData - Tree data - * - * @return {number} count - */ -export function getVisibleNodeCount({ treeData }) { - const traverse = node => { - if ( - !node.children || - node.expanded !== true || - typeof node.children === 'function' - ) { - return 1; - } - - return ( - 1 + - node.children.reduce( - (total, currentNode) => total + traverse(currentNode), - 0 - ) - ); - }; - - return treeData.reduce( - (total, currentNode) => total + traverse(currentNode), - 0 - ); -} - -/** - * Get the th visible node in the tree data. - * - * @param {!Object[]} treeData - Tree data - * @param {!number} targetIndex - The index of the node to search for - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * - * @return {{ - * node: Object, - * path: []string|[]number, - * lowerSiblingCounts: []number - * }|null} node - The node at targetIndex, or null if not found - */ -export function getVisibleNodeInfoAtIndex({ - treeData, - index: targetIndex, - getNodeKey, -}) { - if (!treeData || treeData.length < 1) { - return null; - } - - // Call the tree traversal with a pseudo-root node - const result = getNodeDataAtTreeIndexOrNextIndex({ - targetIndex, - getNodeKey, - node: { - children: treeData, - expanded: true, - }, - currentIndex: -1, - path: [], - lowerSiblingCounts: [], - isPseudoRoot: true, - }); - - if (result.node) { - return result; - } - - return null; -} - -/** - * Walk descendants depth-first and call a callback on each - * - * @param {!Object[]} treeData - Tree data - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {function} callback - Function to call on each node - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return void - */ -export function walk({ - treeData, - getNodeKey, - callback, - ignoreCollapsed = true, -}) { - if (!treeData || treeData.length < 1) { - return; - } - - walkDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - isPseudoRoot: true, - node: { children: treeData }, - currentIndex: -1, - path: [], - lowerSiblingCounts: [], - }); -} - -/** - * Perform a depth-first transversal of the descendants and - * make a change to every node in the tree - * - * @param {!Object[]} treeData - Tree data - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {function} callback - Function to call on each node - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {Object[]} changedTreeData - The changed tree data - */ -export function map({ - treeData, - getNodeKey, - callback, - ignoreCollapsed = true, -}) { - if (!treeData || treeData.length < 1) { - return []; - } - - return mapDescendants({ - callback, - getNodeKey, - ignoreCollapsed, - isPseudoRoot: true, - node: { children: treeData }, - currentIndex: -1, - path: [], - lowerSiblingCounts: [], - }).node.children; -} - -/** - * Expand or close every node in the tree - * - * @param {!Object[]} treeData - Tree data - * @param {?boolean} expanded - Whether the node is expanded or not - * - * @return {Object[]} changedTreeData - The changed tree data - */ -export function toggleExpandedForAll({ treeData, expanded = true }) { - return map({ - treeData, - callback: ({ node }) => ({ ...node, expanded }), - getNodeKey: ({ treeIndex }) => treeIndex, - ignoreCollapsed: false, - }); -} - -/** - * Replaces node at path with object, or callback-defined object - * - * @param {!Object[]} treeData - * @param {number[]|string[]} path - Array of keys leading up to node to be changed - * @param {function|any} newNode - Node to replace the node at the path with, or a function producing the new node - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {Object[]} changedTreeData - The changed tree data - */ -export function changeNodeAtPath({ - treeData, - path, - newNode, - getNodeKey, - ignoreCollapsed = true, -}) { - const RESULT_MISS = 'RESULT_MISS'; - const traverse = ({ - isPseudoRoot = false, - node, - currentTreeIndex, - pathIndex, - }) => { - if ( - !isPseudoRoot && - getNodeKey({ node, treeIndex: currentTreeIndex }) !== path[pathIndex] - ) { - return RESULT_MISS; - } - - if (pathIndex >= path.length - 1) { - // If this is the final location in the path, return its changed form - return typeof newNode === 'function' - ? newNode({ node, treeIndex: currentTreeIndex }) - : newNode; - } - if (!node.children) { - // If this node is part of the path, but has no children, return the unchanged node - throw new Error('Path referenced children of node with no children.'); - } - - let nextTreeIndex = currentTreeIndex + 1; - for (let i = 0; i < node.children.length; i += 1) { - const result = traverse({ - node: node.children[i], - currentTreeIndex: nextTreeIndex, - pathIndex: pathIndex + 1, - }); - - // If the result went down the correct path - if (result !== RESULT_MISS) { - if (result) { - // If the result was truthy (in this case, an object), - // pass it to the next level of recursion up - return { - ...node, - children: [ - ...node.children.slice(0, i), - result, - ...node.children.slice(i + 1), - ], - }; - } - // If the result was falsy (returned from the newNode function), then - // delete the node from the array. - return { - ...node, - children: [ - ...node.children.slice(0, i), - ...node.children.slice(i + 1), - ], - }; - } - - nextTreeIndex += - 1 + getDescendantCount({ node: node.children[i], ignoreCollapsed }); - } - - return RESULT_MISS; - }; - - // Use a pseudo-root node in the beginning traversal - const result = traverse({ - node: { children: treeData }, - currentTreeIndex: -1, - pathIndex: -1, - isPseudoRoot: true, - }); - - if (result === RESULT_MISS) { - throw new Error('No node found at the given path.'); - } - - return result.children; -} - -/** - * Removes the node at the specified path and returns the resulting treeData. - * - * @param {!Object[]} treeData - * @param {number[]|string[]} path - Array of keys leading up to node to be deleted - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {Object[]} changedTreeData - The tree data with the node removed - */ -export function removeNodeAtPath({ - treeData, - path, - getNodeKey, - ignoreCollapsed = true, -}) { - return changeNodeAtPath({ - treeData, - path, - getNodeKey, - ignoreCollapsed, - newNode: null, // Delete the node - }); -} - -/** - * Removes the node at the specified path and returns the resulting treeData. - * - * @param {!Object[]} treeData - * @param {number[]|string[]} path - Array of keys leading up to node to be deleted - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {Object} result - * @return {Object[]} result.treeData - The tree data with the node removed - * @return {Object} result.node - The node that was removed - * @return {number} result.treeIndex - The previous treeIndex of the removed node - */ -export function removeNode({ - treeData, - path, - getNodeKey, - ignoreCollapsed = true, -}) { - let removedNode = null; - let removedTreeIndex = null; - const nextTreeData = changeNodeAtPath({ - treeData, - path, - getNodeKey, - ignoreCollapsed, - newNode: ({ node, treeIndex }) => { - // Store the target node and delete it from the tree - removedNode = node; - removedTreeIndex = treeIndex; - - return null; - }, - }); - - return { - treeData: nextTreeData, - node: removedNode, - treeIndex: removedTreeIndex, - }; -} - -/** - * Gets the node at the specified path - * - * @param {!Object[]} treeData - * @param {number[]|string[]} path - Array of keys leading up to node to be deleted - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {Object|null} nodeInfo - The node info at the given path, or null if not found - */ -export function getNodeAtPath({ - treeData, - path, - getNodeKey, - ignoreCollapsed = true, -}) { - let foundNodeInfo = null; - - try { - changeNodeAtPath({ - treeData, - path, - getNodeKey, - ignoreCollapsed, - newNode: ({ node, treeIndex }) => { - foundNodeInfo = { node, treeIndex }; - return node; - }, - }); - } catch (err) { - // Ignore the error -- the null return will be explanation enough - } - - return foundNodeInfo; -} - -/** - * Adds the node to the specified parent and returns the resulting treeData. - * - * @param {!Object[]} treeData - * @param {!Object} newNode - The node to insert - * @param {number|string} parentKey - The key of the to-be parentNode of the node - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * @param {boolean=} expandParent - If true, expands the parentNode specified by parentPath - * @param {boolean=} addAsFirstChild - If true, adds new node as first child of tree - * - * @return {Object} result - * @return {Object[]} result.treeData - The updated tree data - * @return {number} result.treeIndex - The tree index at which the node was inserted - */ -export function addNodeUnderParent({ - treeData, - newNode, - parentKey = null, - getNodeKey, - ignoreCollapsed = true, - expandParent = false, - addAsFirstChild = false, -}) { - if (parentKey === null) { - return addAsFirstChild - ? { - treeData: [newNode, ...(treeData || [])], - treeIndex: 0, - } - : { - treeData: [...(treeData || []), newNode], - treeIndex: (treeData || []).length, - }; - } - - let insertedTreeIndex = null; - let hasBeenAdded = false; - const changedTreeData = map({ - treeData, - getNodeKey, - ignoreCollapsed, - callback: ({ node, treeIndex, path }) => { - const key = path ? path[path.length - 1] : null; - // Return nodes that are not the parent as-is - if (hasBeenAdded || key !== parentKey) { - return node; - } - hasBeenAdded = true; - - const parentNode = { - ...node, - }; - - if (expandParent) { - parentNode.expanded = true; - } - - // If no children exist yet, just add the single newNode - if (!parentNode.children) { - insertedTreeIndex = treeIndex + 1; - return { - ...parentNode, - children: [newNode], - }; - } - - if (typeof parentNode.children === 'function') { - throw new Error('Cannot add to children defined by a function'); - } - - let nextTreeIndex = treeIndex + 1; - for (let i = 0; i < parentNode.children.length; i += 1) { - nextTreeIndex += - 1 + - getDescendantCount({ node: parentNode.children[i], ignoreCollapsed }); - } - - insertedTreeIndex = nextTreeIndex; - - const children = addAsFirstChild - ? [newNode, ...parentNode.children] - : [...parentNode.children, newNode]; - - return { - ...parentNode, - children, - }; - }, - }); - - if (!hasBeenAdded) { - throw new Error('No node found with the given key.'); - } - - return { - treeData: changedTreeData, - treeIndex: insertedTreeIndex, - }; -} - -function addNodeAtDepthAndIndex({ - targetDepth, - minimumTreeIndex, - newNode, - ignoreCollapsed, - expandParent, - isPseudoRoot = false, - isLastChild, - node, - currentIndex, - currentDepth, - getNodeKey, - path = [], -}) { - const selfPath = n => - isPseudoRoot - ? [] - : [...path, getNodeKey({ node: n, treeIndex: currentIndex })]; - - // If the current position is the only possible place to add, add it - if ( - currentIndex >= minimumTreeIndex - 1 || - (isLastChild && !(node.children && node.children.length)) - ) { - if (typeof node.children === 'function') { - throw new Error('Cannot add to children defined by a function'); - } else { - const extraNodeProps = expandParent ? { expanded: true } : {}; - const nextNode = { - ...node, - - ...extraNodeProps, - children: node.children ? [newNode, ...node.children] : [newNode], - }; - - return { - node: nextNode, - nextIndex: currentIndex + 2, - insertedTreeIndex: currentIndex + 1, - parentPath: selfPath(nextNode), - parentNode: isPseudoRoot ? null : nextNode, - }; - } - } - - // If this is the target depth for the insertion, - // i.e., where the newNode can be added to the current node's children - if (currentDepth >= targetDepth - 1) { - // Skip over nodes with no children or hidden children - if ( - !node.children || - typeof node.children === 'function' || - (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) - ) { - return { node, nextIndex: currentIndex + 1 }; - } - - // Scan over the children to see if there's a place among them that fulfills - // the minimumTreeIndex requirement - let childIndex = currentIndex + 1; - let insertedTreeIndex = null; - let insertIndex = null; - for (let i = 0; i < node.children.length; i += 1) { - // If a valid location is found, mark it as the insertion location and - // break out of the loop - if (childIndex >= minimumTreeIndex) { - insertedTreeIndex = childIndex; - insertIndex = i; - break; - } - - // Increment the index by the child itself plus the number of descendants it has - childIndex += - 1 + getDescendantCount({ node: node.children[i], ignoreCollapsed }); - } - - // If no valid indices to add the node were found - if (insertIndex === null) { - // If the last position in this node's children is less than the minimum index - // and there are more children on the level of this node, return without insertion - if (childIndex < minimumTreeIndex && !isLastChild) { - return { node, nextIndex: childIndex }; - } - - // Use the last position in the children array to insert the newNode - insertedTreeIndex = childIndex; - insertIndex = node.children.length; - } - - // Insert the newNode at the insertIndex - const nextNode = { - ...node, - children: [ - ...node.children.slice(0, insertIndex), - newNode, - ...node.children.slice(insertIndex), - ], - }; - - // Return node with successful insert result - return { - node: nextNode, - nextIndex: childIndex, - insertedTreeIndex, - parentPath: selfPath(nextNode), - parentNode: isPseudoRoot ? null : nextNode, - }; - } - - // Skip over nodes with no children or hidden children - if ( - !node.children || - typeof node.children === 'function' || - (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) - ) { - return { node, nextIndex: currentIndex + 1 }; - } - - // Get all descendants - let insertedTreeIndex = null; - let pathFragment = null; - let parentNode = null; - let childIndex = currentIndex + 1; - let newChildren = node.children; - if (typeof newChildren !== 'function') { - newChildren = newChildren.map((child, i) => { - if (insertedTreeIndex !== null) { - return child; - } - - const mapResult = addNodeAtDepthAndIndex({ - targetDepth, - minimumTreeIndex, - newNode, - ignoreCollapsed, - expandParent, - isLastChild: isLastChild && i === newChildren.length - 1, - node: child, - currentIndex: childIndex, - currentDepth: currentDepth + 1, - getNodeKey, - path: [], // Cannot determine the parent path until the children have been processed - }); - - if ('insertedTreeIndex' in mapResult) { - ({ - insertedTreeIndex, - parentNode, - parentPath: pathFragment, - } = mapResult); - } - - childIndex = mapResult.nextIndex; - - return mapResult.node; - }); - } - - const nextNode = { ...node, children: newChildren }; - const result = { - node: nextNode, - nextIndex: childIndex, - }; - - if (insertedTreeIndex !== null) { - result.insertedTreeIndex = insertedTreeIndex; - result.parentPath = [...selfPath(nextNode), ...pathFragment]; - result.parentNode = parentNode; - } - - return result; -} - -/** - * Insert a node into the tree at the given depth, after the minimum index - * - * @param {!Object[]} treeData - Tree data - * @param {!number} depth - The depth to insert the node at (the first level of the array being depth 0) - * @param {!number} minimumTreeIndex - The lowest possible treeIndex to insert the node at - * @param {!Object} newNode - The node to insert into the tree - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * @param {boolean=} expandParent - If true, expands the parent of the inserted node - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * - * @return {Object} result - * @return {Object[]} result.treeData - The tree data with the node added - * @return {number} result.treeIndex - The tree index at which the node was inserted - * @return {number[]|string[]} result.path - Array of keys leading to the node location after insertion - * @return {Object} result.parentNode - The parent node of the inserted node - */ -export function insertNode({ - treeData, - depth: targetDepth, - minimumTreeIndex, - newNode, - getNodeKey = () => {}, - ignoreCollapsed = true, - expandParent = false, -}) { - if (!treeData && targetDepth === 0) { - return { - treeData: [newNode], - treeIndex: 0, - path: [getNodeKey({ node: newNode, treeIndex: 0 })], - parentNode: null, - }; - } - - const insertResult = addNodeAtDepthAndIndex({ - targetDepth, - minimumTreeIndex, - newNode, - ignoreCollapsed, - expandParent, - getNodeKey, - isPseudoRoot: true, - isLastChild: true, - node: { children: treeData }, - currentIndex: -1, - currentDepth: -1, - }); - - if (!('insertedTreeIndex' in insertResult)) { - throw new Error('No suitable position found to insert.'); - } - - const treeIndex = insertResult.insertedTreeIndex; - return { - treeData: insertResult.node.children, - treeIndex, - path: [ - ...insertResult.parentPath, - getNodeKey({ node: newNode, treeIndex }), - ], - parentNode: insertResult.parentNode, - }; -} - -/** - * Get tree data flattened. - * - * @param {!Object[]} treeData - Tree data - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {boolean=} ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` - * - * @return {{ - * node: Object, - * path: []string|[]number, - * lowerSiblingCounts: []number - * }}[] nodes - The node array - */ -export function getFlatDataFromTree({ - treeData, - getNodeKey, - ignoreCollapsed = true, -}) { - if (!treeData || treeData.length < 1) { - return []; - } - - const flattened = []; - walk({ - treeData, - getNodeKey, - ignoreCollapsed, - callback: nodeInfo => { - flattened.push(nodeInfo); - }, - }); - - return flattened; -} - -/** - * Generate a tree structure from flat data. - * - * @param {!Object[]} flatData - * @param {!function=} getKey - Function to get the key from the nodeData - * @param {!function=} getParentKey - Function to get the parent key from the nodeData - * @param {string|number=} rootKey - The value returned by `getParentKey` that corresponds to the root node. - * For example, if your nodes have id 1-99, you might use rootKey = 0 - * - * @return {Object[]} treeData - The flat data represented as a tree - */ -export function getTreeFromFlatData({ - flatData, - getKey = node => node.id, - getParentKey = node => node.parentId, - rootKey = '0', -}) { - if (!flatData) { - return []; - } - - const childrenToParents = {}; - flatData.forEach(child => { - const parentKey = getParentKey(child); - - if (parentKey in childrenToParents) { - childrenToParents[parentKey].push(child); - } else { - childrenToParents[parentKey] = [child]; - } - }); - - if (!(rootKey in childrenToParents)) { - return []; - } - - const trav = parent => { - const parentKey = getKey(parent); - if (parentKey in childrenToParents) { - return { - ...parent, - children: childrenToParents[parentKey].map(child => trav(child)), - }; - } - - return { ...parent }; - }; - - return childrenToParents[rootKey].map(child => trav(child)); -} - -/** - * Check if a node is a descendant of another node. - * - * @param {!Object} older - Potential ancestor of younger node - * @param {!Object} younger - Potential descendant of older node - * - * @return {boolean} - */ -export function isDescendant(older, younger) { - return ( - !!older.children && - typeof older.children !== 'function' && - older.children.some( - child => child === younger || isDescendant(child, younger) - ) - ); -} - -/** - * Get the maximum depth of the children (the depth of the root node is 0). - * - * @param {!Object} node - Node in the tree - * @param {?number} depth - The current depth - * - * @return {number} maxDepth - The deepest depth in the tree - */ -export function getDepth(node, depth = 0) { - if (!node.children) { - return depth; - } - - if (typeof node.children === 'function') { - return depth + 1; - } - - return node.children.reduce( - (deepest, child) => Math.max(deepest, getDepth(child, depth + 1)), - depth - ); -} - -/** - * Find nodes matching a search query in the tree, - * - * @param {!function} getNodeKey - Function to get the key from the nodeData and tree index - * @param {!Object[]} treeData - Tree data - * @param {?string|number} searchQuery - Function returning a boolean to indicate whether the node is a match or not - * @param {!function} searchMethod - Function returning a boolean to indicate whether the node is a match or not - * @param {?number} searchFocusOffset - The offset of the match to focus on - * (e.g., 0 focuses on the first match, 1 on the second) - * @param {boolean=} expandAllMatchPaths - If true, expands the paths to any matched node - * @param {boolean=} expandFocusMatchPaths - If true, expands the path to the focused node - * - * @return {Object[]} matches - An array of objects containing the matching `node`s, their `path`s and `treeIndex`s - * @return {Object[]} treeData - The original tree data with all relevant nodes expanded. - * If expandAllMatchPaths and expandFocusMatchPaths are both false, - * it will be the same as the original tree data. - */ -export function find({ - getNodeKey, - treeData, - searchQuery, - searchMethod, - searchFocusOffset, - expandAllMatchPaths = false, - expandFocusMatchPaths = true, -}) { - let matchCount = 0; - const trav = ({ isPseudoRoot = false, node, currentIndex, path = [] }) => { - let matches = []; - let isSelfMatch = false; - let hasFocusMatch = false; - // The pseudo-root is not considered in the path - const selfPath = isPseudoRoot - ? [] - : [...path, getNodeKey({ node, treeIndex: currentIndex })]; - const extraInfo = isPseudoRoot - ? null - : { - path: selfPath, - treeIndex: currentIndex, - }; - - // Nodes with with children that aren't lazy - const hasChildren = - node.children && - typeof node.children !== 'function' && - node.children.length > 0; - - // Examine the current node to see if it is a match - if (!isPseudoRoot && searchMethod({ ...extraInfo, node, searchQuery })) { - if (matchCount === searchFocusOffset) { - hasFocusMatch = true; - } - - // Keep track of the number of matching nodes, so we know when the searchFocusOffset - // is reached - matchCount += 1; - - // We cannot add this node to the matches right away, as it may be changed - // during the search of the descendants. The entire node is used in - // comparisons between nodes inside the `matches` and `treeData` results - // of this method (`find`) - isSelfMatch = true; - } - - let childIndex = currentIndex; - const newNode = { ...node }; - if (hasChildren) { - // Get all descendants - newNode.children = newNode.children.map(child => { - const mapResult = trav({ - node: child, - currentIndex: childIndex + 1, - path: selfPath, - }); - - // Ignore hidden nodes by only advancing the index counter to the returned treeIndex - // if the child is expanded. - // - // The child could have been expanded from the start, - // or expanded due to a matching node being found in its descendants - if (mapResult.node.expanded) { - childIndex = mapResult.treeIndex; - } else { - childIndex += 1; - } - - if (mapResult.matches.length > 0 || mapResult.hasFocusMatch) { - matches = [...matches, ...mapResult.matches]; - if (mapResult.hasFocusMatch) { - hasFocusMatch = true; - } - - // Expand the current node if it has descendants matching the search - // and the settings are set to do so. - if ( - (expandAllMatchPaths && mapResult.matches.length > 0) || - ((expandAllMatchPaths || expandFocusMatchPaths) && - mapResult.hasFocusMatch) - ) { - newNode.expanded = true; - } - } - - return mapResult.node; - }); - } - - // Cannot assign a treeIndex to hidden nodes - if (!isPseudoRoot && !newNode.expanded) { - matches = matches.map(match => ({ - ...match, - treeIndex: null, - })); - } - - // Add this node to the matches if it fits the search criteria. - // This is performed at the last minute so newNode can be sent in its final form. - if (isSelfMatch) { - matches = [{ ...extraInfo, node: newNode }, ...matches]; - } - - return { - node: matches.length > 0 ? newNode : node, - matches, - hasFocusMatch, - treeIndex: childIndex, - }; - }; - - const result = trav({ - node: { children: treeData }, - isPseudoRoot: true, - currentIndex: -1, - }); - - return { - matches: result.matches, - treeData: result.node.children, - }; -} diff --git a/src/utils/tree-data-utils.spec.ts b/src/utils/tree-data-utils.spec.ts new file mode 100644 index 00000000..068ed0e7 --- /dev/null +++ b/src/utils/tree-data-utils.spec.ts @@ -0,0 +1,2040 @@ +import { + getVisibleNodeCount, + getVisibleNodeInfoAtIndex, + changeNodeAtPath, + addNodeUnderParent, + getTreeFromFlatData, + getNodeAtPath, + getFlatDataFromTree, + walk, + map, + insertNode, + isDescendant, + getDepth, + getDescendantCount, + find, + toggleExpandedForAll, +} from './tree-data-utils'; +import { TreeNode, TreeData } from '../types'; + +type OnlyChildren = { + children?: OnlyChildren[] | Function; + expanded?: boolean; + nodeId?: string; +}; +function fill(input: OnlyChildren): TreeNode; +function fill(input: OnlyChildren[]): TreeNode[]; +function fill(input: OnlyChildren | OnlyChildren[]): TreeNode | TreeNode[] { + if (Array.isArray(input)) { + return input.map(a => fill(a)); + } + + let { children, ...otherInput } = input; + if (input.children) { + if (typeof input.children !== 'function') { + children = fill(input.children); + } + } + + return children + ? { nodeId: 'd', ...otherInput, children: children as TreeNode['children'] } + : { nodeId: 'd', ...otherInput }; +} + +describe('getVisibleNodeCount', () => { + it('should handle flat data', () => { + expect(getVisibleNodeCount(fill([{}, {}]))).toEqual(2); + }); + + it('should handle hidden nested data', () => { + expect( + getVisibleNodeCount( + fill([ + { + children: [ + { + children: [{}, {}], + }, + { + children: [{}], + }, + ], + }, + {}, + ]) + ) + ).toEqual(2); + }); + + it('should handle functions', () => { + expect( + getVisibleNodeCount( + fill([ + { + expanded: true, + children: [ + { + expanded: true, + children: [ + { + expanded: true, + children: () => ({ nodeId: '1' }), + }, + {}, + ], + }, + { + children: [{}], + }, + ], + }, + {}, + ]) + ) + ).toEqual(6); + }); + + it('should handle partially expanded nested data', () => { + expect( + getVisibleNodeCount( + fill([ + { + expanded: true, + children: [ + { + expanded: true, + children: [{}, {}], + }, + { + children: [{}], + }, + ], + }, + {}, + ]) + ) + ).toEqual(6); + }); + + it('should handle fully expanded nested data', () => { + expect( + getVisibleNodeCount( + fill([ + { + expanded: true, + children: [ + { + expanded: true, + children: [{}, {}], + }, + { + expanded: true, + children: [{}], + }, + ], + }, + {}, + ]) + ) + ).toEqual(7); + }); +}); + +describe('getVisibleNodeInfoAtIndex', () => { + it('should handle empty data', () => { + expect(getVisibleNodeInfoAtIndex([], 1)).toEqual(null); + }); + + it('should handle flat data', () => { + expect( + getVisibleNodeInfoAtIndex(fill([{ nodeId: '0' }]), 0).node.nodeId + ).toEqual('0'); + expect( + getVisibleNodeInfoAtIndex(fill([{ nodeId: '0' }, { nodeId: '1' }]), 1) + .node.nodeId + ).toEqual('1'); + }); + + it('should handle hidden nested data', () => { + const result = getVisibleNodeInfoAtIndex( + fill([ + { + nodeId: '0', + children: [ + { + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ]), + 1 + ); + + expect(result.node.nodeId).toEqual('6'); + expect(result.path).toEqual(['6']); + expect(result.lowerSiblingCounts).toEqual([0]); + }); + + it('should handle partially expanded nested data', () => { + const result = getVisibleNodeInfoAtIndex( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + 3 + ); + + expect(result.node.nodeId).toEqual('5'); + expect(result.path).toEqual(['0', '4', '5']); + expect(result.lowerSiblingCounts).toEqual([1, 0, 0]); + }); + + it('should handle fully expanded nested data', () => { + const result = getVisibleNodeInfoAtIndex( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + 5 + ); + + expect(result.node.nodeId).toEqual('5'); + expect(result.path).toEqual(['0', '4', '5']); + expect(result.lowerSiblingCounts).toEqual([1, 0, 0]); + }); + + it('should handle an index that is larger than the data', () => { + expect( + getVisibleNodeInfoAtIndex( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + 7 + ) + ).toEqual(null); + }); +}); + +describe('getNodeAtPath', () => { + it('should handle empty data', () => { + expect(getNodeAtPath([], ['1'])).toEqual(null); + }); + + it('should handle flat data', () => { + expect(getNodeAtPath([{ nodeId: '0' }], ['0']).node.nodeId).toEqual('0'); + expect( + getNodeAtPath([{ nodeId: '0' }, { nodeId: '1' }], ['1']).node.nodeId + ).toEqual('1'); + }); + + it('should handle partially expanded nested data', () => { + const result = getNodeAtPath( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + ['0', '4', '5'] + ); + + expect(result.node.nodeId).toEqual('5'); + }); + + it('should handle fully expanded nested data', () => { + const result = getNodeAtPath( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + ['0', '4', '5'] + ); + + expect(result.node.nodeId).toEqual('5'); + }); + + it('should handle a nodeId not in the data', () => { + expect( + getNodeAtPath( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ], + ['7'] + ) + ).toEqual(null); + }); +}); + +describe('getFlatDataFromTree', () => { + it('should handle empty data', () => { + expect(getFlatDataFromTree([])).toEqual([]); + }); + + it('should handle flat data', () => { + expect(getFlatDataFromTree([{ nodeId: '0' }], true)).toEqual([ + { + node: { nodeId: '0' }, + parentNode: null, + path: ['0'], + lowerSiblingCounts: [0], + treeIndex: 0, + }, + ]); + + expect( + getFlatDataFromTree([{ nodeId: '0' }, { nodeId: '1' }], true) + ).toEqual([ + { + node: { nodeId: '0' }, + parentNode: null, + path: ['0'], + lowerSiblingCounts: [1], + treeIndex: 0, + }, + { + node: { nodeId: '1' }, + parentNode: null, + path: ['1'], + lowerSiblingCounts: [0], + treeIndex: 1, + }, + ]); + }); + + it('should handle hidden nested data', () => { + const treeData = [ + { + nodeId: '0', + children: [ + { + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ]; + + expect(getFlatDataFromTree(treeData, true)).toEqual([ + { + node: treeData[0], + parentNode: null, + path: ['0'], + lowerSiblingCounts: [1], + treeIndex: 0, + }, + { + node: treeData[1], + parentNode: null, + path: ['6'], + lowerSiblingCounts: [0], + treeIndex: 1, + }, + ]); + }); + + it('should handle partially expanded nested data', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ]; + + expect(getFlatDataFromTree(treeData, true)).toEqual([ + { + node: treeData[0], + parentNode: null, + path: ['0'], + lowerSiblingCounts: [1], + treeIndex: 0, + }, + { + node: treeData[0].children[0], + parentNode: treeData[0], + path: ['0', '1'], + lowerSiblingCounts: [1, 1], + treeIndex: 1, + }, + { + node: treeData[0].children[1], + parentNode: treeData[0], + path: ['0', '4'], + lowerSiblingCounts: [1, 0], + treeIndex: 2, + }, + { + node: treeData[0].children[1].children[0], + parentNode: treeData[0].children[1], + path: ['0', '4', '5'], + lowerSiblingCounts: [1, 0, 0], + treeIndex: 3, + }, + { + node: treeData[1], + parentNode: null, + path: ['6'], + lowerSiblingCounts: [0], + treeIndex: 4, + }, + ]); + }); + + it('should handle fully expanded nested data', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { + expanded: true, + nodeId: '4', + children: [{ nodeId: '5' }], + }, + ], + }, + { nodeId: '6' }, + ]; + + expect(getFlatDataFromTree(treeData, true)).toEqual([ + { + node: treeData[0], + parentNode: null, + path: ['0'], + lowerSiblingCounts: [1], + treeIndex: 0, + }, + { + node: treeData[0].children[0], + parentNode: treeData[0], + path: ['0', '1'], + lowerSiblingCounts: [1, 1], + treeIndex: 1, + }, + { + node: treeData[0].children[0].children[0], + parentNode: treeData[0].children[0], + path: ['0', '1', '2'], + lowerSiblingCounts: [1, 1, 1], + treeIndex: 2, + }, + { + node: treeData[0].children[0].children[1], + parentNode: treeData[0].children[0], + path: ['0', '1', '3'], + lowerSiblingCounts: [1, 1, 0], + treeIndex: 3, + }, + { + node: treeData[0].children[1], + parentNode: treeData[0], + path: ['0', '4'], + lowerSiblingCounts: [1, 0], + treeIndex: 4, + }, + { + node: treeData[0].children[1].children[0], + parentNode: treeData[0].children[1], + path: ['0', '4', '5'], + lowerSiblingCounts: [1, 0, 0], + treeIndex: 5, + }, + { + node: treeData[1], + parentNode: null, + path: ['6'], + lowerSiblingCounts: [0], + treeIndex: 6, + }, + ]); + }); +}); + +describe('changeNodeAtPath', () => { + it('should handle empty data', () => { + const noChildrenError = new Error( + 'Path referenced children of node with no children.' + ); + const noNodeError = new Error('No node found at the given path.'); + expect(() => changeNodeAtPath([], ['1'], fill({}))).toThrow(noNodeError); + }); + + it('should handle flat data', () => { + expect( + changeNodeAtPath([{ nodeId: '0' }], ['0'], { nodeId: '1' }) + ).toEqual([{ nodeId: '1' }]); + + expect( + changeNodeAtPath([{ nodeId: '0' }, { nodeId: 'a' }], ['a'], { + nodeId: '1', + }) + ).toEqual([{ nodeId: '0' }, { nodeId: '1' }]); + }); + + it('should handle nested data', () => { + const result = changeNodeAtPath( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: 'b', + children: [{ nodeId: '2' }, { nodeId: '3' }, { nodeId: 'f' }], + }, + { + expanded: true, + nodeId: 'r', + children: [{ nodeId: '5' }, { nodeId: '8' }, { nodeId: '7' }], + }, + ], + }, + { nodeId: '6' }, + ], + ['0', 'r', '7'], + { nodeId: 'pancake' } + ); + + expect(result[0].children[1].children[2].nodeId).toEqual('pancake'); + }); + + it('should handle adding children', () => { + const result = changeNodeAtPath( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: 'b', + children: [{ nodeId: '2' }, { nodeId: '3' }, { nodeId: 'f' }], + }, + { + expanded: true, + nodeId: 'r', + children: [{ nodeId: '5' }, { nodeId: '8' }, { nodeId: '7' }], + }, + ], + }, + { nodeId: '6' }, + ], + ['0', 'r', '7'], + ({ node }) => ({ + ...node, + children: [{ nodeId: 'pancake' }], + }) + ); + + expect(result[0].children[1].children[2].children[0].nodeId).toEqual( + 'pancake' + ); + }); + + it('should handle adding children to the root', () => { + expect( + changeNodeAtPath([], [], ({ node }) => ({ + ...node, + children: [...(node.children as TreeNode[]), { nodeId: '1' }], + })) + ).toEqual([{ nodeId: '1' }]); + + expect( + changeNodeAtPath([{ nodeId: '0' }], [], ({ node }) => ({ + ...node, + children: [...(node.children as TreeNode[]), { nodeId: '1' }], + })) + ).toEqual([{ nodeId: '0' }, { nodeId: '1' }]); + }); + + it('should delete data when falsey node passed', () => { + const result = changeNodeAtPath( + [ + { + expanded: true, + nodeId: 'b', + children: [{ nodeId: 'f' }], + }, + { + expanded: true, + nodeId: 'r', + children: [{ nodeId: '7' }], + }, + { nodeId: '6' }, + ], + ['r', '7'], + null + ); + + expect(result[1].children.length).toEqual(0); + }); + + it('should delete data on the top level', () => { + const treeData = [ + { + expanded: true, + nodeId: 'b', + children: [{ nodeId: 'f' }], + }, + { + expanded: true, + nodeId: 'r', + children: [{ nodeId: '7' }], + }, + { nodeId: '6' }, + ]; + const result = changeNodeAtPath(treeData, ['r'], null); + + expect(result).toEqual([treeData[0], treeData[2]]); + }); + + it('should handle a path that is too long', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + }, + ]; + + expect(() => + changeNodeAtPath(treeData, ['0', '1', '2', '4'], { nodeId: 'aa' }) + ).toThrow(new Error('Path referenced children of node with no children.')); + }); + + it('should handle a path that does not exist', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + }, + ]; + + expect(() => + changeNodeAtPath(treeData, ['0', '2'], { nodeId: 'aa' }) + ).toThrowError('No node found at the given path.'); + }); +}); + +describe('addNodeUnderParent', () => { + it('should handle empty data', () => { + const node = fill({}); + expect(addNodeUnderParent([], node)).toEqual({ + treeData: [node], + treeIndex: 0, + }); + }); + + it('should handle a parentPath that does not exist', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + }, + ]; + + expect(() => + addNodeUnderParent(treeData, { nodeId: '1' }, 'fake') + ).toThrowError('No node found with the given nodeId.'); + }); + + it('should handle flat data', () => { + // Older sibling of only node + expect( + addNodeUnderParent([{ nodeId: '0' }], { nodeId: '1' }, null) + ).toEqual({ treeData: [{ nodeId: '0' }, { nodeId: '1' }], treeIndex: 1 }); + + // Child of only node + expect(addNodeUnderParent([{ nodeId: '0' }], { nodeId: '1' }, '0')).toEqual( + { + treeData: [{ nodeId: '0', children: [{ nodeId: '1' }] }], + treeIndex: 1, + } + ); + + expect( + addNodeUnderParent( + [{ nodeId: '0' }, { nodeId: 'a' }], + { nodeId: '1' }, + 'a' + ) + ).toEqual({ + treeData: [{ nodeId: '0' }, { nodeId: 'a', children: [{ nodeId: '1' }] }], + treeIndex: 2, + }); + }); + + // Tree looks like this + // /\ + // 0 6 + // / \ + // 1 5 + // / \ + // 2 3 + // \ + // 4 + const nestedParams = { + treeData: [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [ + { nodeId: '2' }, + { + expanded: false, + nodeId: '3', + children: [{ nodeId: '4' }], + }, + ], + }, + { nodeId: '5' }, + ], + }, + { nodeId: '6' }, + ], + newNode: { nodeId: 'new' }, + }; + + it('should handle nested data #1', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '0' + ); + + expect(result.treeData[0].children[2]).toEqual(nestedParams.newNode); + expect(result.treeIndex).toEqual(5); + }); + + it('should handle nested data #2', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '1' + ); + + expect(result.treeData[0].children[0].children[2]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(4); + }); + + it('should handle nested data #3', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '3' + ); + + expect(result.treeData[0].children[0].children[1].children[1]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(5); + }); + + it('should handle nested data #1 (using tree index as key)', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '0' + ); + + expect(result.treeData[0].children[2]).toEqual(nestedParams.newNode); + expect(result.treeIndex).toEqual(5); + }); + + it('should handle nested data #2 (using tree index as key)', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '1' + ); + + expect(result.treeData[0].children[0].children[2]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(4); + }); + + it('should handle nested data #3 (using tree index as key)', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '3' + ); + + expect(result.treeData[0].children[0].children[1].children[1]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(5); + }); + + it('should add new node as last child by default', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '0' + ); + + const [existingChild0, existingChild1, expectedNewNode] = result.treeData[0] + .children as TreeNode[]; + + expect(expectedNewNode).toEqual(nestedParams.newNode); + expect([existingChild0, existingChild1]).toEqual( + nestedParams.treeData[0].children + ); + }); + + it('should add new node as first child if addAsFirstChild is true', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + '0', + true + ); + + const [expectedNewNode, ...previousChildren] = result.treeData[0] + .children as TreeNode[]; + + expect(expectedNewNode).toEqual(nestedParams.newNode); + expect(previousChildren).toEqual(nestedParams.treeData[0].children); + }); + + it('should add new node as first child under root if addAsFirstChild is true', () => { + const result = addNodeUnderParent( + nestedParams.treeData, + nestedParams.newNode, + null, + true + ); + + const [expectedNewNode, ...previousTreeData] = result.treeData; + + expect(expectedNewNode).toEqual(nestedParams.newNode); + expect(previousTreeData).toEqual(nestedParams.treeData); + }); +}); + +describe('insertNode', () => { + it('should handle empty data', () => { + const node = { nodeId: '0' }; + expect(insertNode([], node, 0, 0)).toEqual({ + parentNode: null, + treeData: [node], + treeIndex: 0, + path: ['0'], + }); + }); + + it('should handle a depth that is deeper than any branch in the tree', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + }, + ]; + + expect(insertNode(treeData, { nodeId: 'new' }, 4, 0).treeData[0]).toEqual({ + nodeId: 'new', + }); + }); + + it('should handle a minimumTreeIndex that is too big', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [ + { + expanded: true, + nodeId: '1', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + }, + { nodeId: '4' }, + ]; + + let insertResult = insertNode(treeData, { nodeId: 'new' }, 0, 15); + expect(insertResult.treeData[2]).toEqual({ nodeId: 'new' }); + expect(insertResult.treeIndex).toEqual(5); + expect(insertResult.path).toEqual(['new']); + + insertResult = insertNode(treeData, { nodeId: 'new' }, 2, 15); + + expect(insertResult.treeData[1].children[0]).toEqual({ nodeId: 'new' }); + expect(insertResult.treeIndex).toEqual(5); + expect(insertResult.path).toEqual(['4', 'new']); + }); + + it('should handle flat data (before)', () => { + expect(insertNode([{ nodeId: '0' }], { nodeId: '1' }, 0, 0)).toEqual({ + parentNode: null, + treeData: [{ nodeId: '1' }, { nodeId: '0' }], + treeIndex: 0, + path: ['1'], + }); + }); + + it('should handle flat data (after)', () => { + expect(insertNode([{ nodeId: '0' }], { nodeId: '1' }, 0, 1)).toEqual({ + parentNode: null, + treeData: [{ nodeId: '0' }, { nodeId: '1' }], + treeIndex: 1, + path: ['1'], + }); + }); + + it('should handle flat data (child)', () => { + expect(insertNode([{ nodeId: '0' }], { nodeId: '1' }, 1, 1)).toEqual({ + parentNode: { nodeId: '0', children: [{ nodeId: '1' }] }, + treeData: [{ nodeId: '0', children: [{ nodeId: '1' }] }], + treeIndex: 1, + path: ['0', '1'], + }); + }); + + // Tree looks like this + // /\ + // 0 6 + // / \ + // 1 5 + // / \ + // 2 3 + // \ + // 4 + const nestedParams = { + treeData: [ + // Depth 0 + { + expanded: true, + nodeId: '0', + children: [ + // Depth 1 + { + expanded: true, + nodeId: '1', + children: [ + // Depth 2 + { nodeId: '2' }, + { + expanded: false, + nodeId: '3', + children: [ + // Depth 3 + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + }, + { nodeId: '6' }, + ], + }, + { nodeId: '7' }, + ], + newNode: { nodeId: 'new' }, + }; + + it('should handle nested data #1', () => { + const result = insertNode( + nestedParams.treeData, + nestedParams.newNode, + 1, + 4 + ); + + expect(result.treeData[0].children[1]).toEqual(nestedParams.newNode); + expect(result.treeIndex).toEqual(5); + expect(result.path).toEqual(['0', 'new']); + }); + + it('should handle nested data #2', () => { + let result = insertNode( + nestedParams.treeData, + nestedParams.newNode, + 2, + 5, + true + ); + + expect(result.treeData[0].children[0].children[3]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(5); + expect(result.path).toEqual(['0', '1', 'new']); + + result = insertNode( + nestedParams.treeData, + nestedParams.newNode, + 2, + 5, + false + ); + + expect(result.treeData[0].children[0].children[2]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(5); + expect(result.path).toEqual(['0', '1', 'new']); + }); + + it('should handle nested data #3', () => { + const result = insertNode( + nestedParams.treeData, + nestedParams.newNode, + 3, + 3 + ); + + expect(result.treeData[0].children[0].children[0].children[0]).toEqual( + nestedParams.newNode + ); + expect(result.treeIndex).toEqual(3); + expect(result.path).toEqual(['0', '1', '2', 'new']); + }); + + it('should handle nested data #4', () => { + expect( + insertNode( + [ + { nodeId: '0', expanded: true, children: [{ nodeId: '1' }] }, + { nodeId: '2' }, + ], + { nodeId: 'new' }, + 1, + 3 + ) + ).toEqual({ + parentNode: { nodeId: '2', children: [{ nodeId: 'new' }] }, + treeData: [ + { nodeId: '0', expanded: true, children: [{ nodeId: '1' }] }, + { nodeId: '2', children: [{ nodeId: 'new' }] }, + ], + treeIndex: 3, + path: ['2', 'new'], + }); + }); + + it('should work with a preceding node with children #1', () => { + expect( + insertNode( + [ + { nodeId: '0', children: [{ nodeId: '1' }] }, + { + nodeId: '2', + expanded: true, + children: [{ nodeId: '3' }, { nodeId: '4' }], + }, + ], + { nodeId: 'new' }, + 1, + 3 + ) + ).toEqual({ + parentNode: { + nodeId: '2', + expanded: true, + children: [{ nodeId: '3' }, { nodeId: 'new' }, { nodeId: '4' }], + }, + treeData: [ + { nodeId: '0', children: [{ nodeId: '1' }] }, + { + nodeId: '2', + expanded: true, + children: [{ nodeId: '3' }, { nodeId: 'new' }, { nodeId: '4' }], + }, + ], + treeIndex: 3, + path: ['2', 'new'], + }); + }); + + it('should work with a preceding node with children #2', () => { + expect( + insertNode( + [ + { nodeId: '0', children: [{ nodeId: '1' }] }, + { + nodeId: '2', + expanded: true, + children: [{ nodeId: '3' }, { nodeId: '4' }], + }, + ], + { nodeId: 'new' }, + 2, + 4 + ) + ).toEqual({ + parentNode: { nodeId: '4', children: [{ nodeId: 'new' }] }, + treeData: [ + { nodeId: '0', children: [{ nodeId: '1' }] }, + { + nodeId: '2', + expanded: true, + children: [ + { nodeId: '3' }, + { nodeId: '4', children: [{ nodeId: 'new' }] }, + ], + }, + ], + treeIndex: 4, + path: ['2', '4', 'new'], + }); + }); + + it('should work with a preceding node with children #3', () => { + expect( + insertNode( + [ + { + nodeId: '0', + children: [ + { nodeId: '01' }, + { nodeId: '02' }, + { nodeId: '03' }, + { nodeId: '04' }, + ], + }, + { + nodeId: '1', + expanded: true, + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + ], + { nodeId: 'new' }, + 2, + 4 + ) + ).toEqual({ + parentNode: { nodeId: '3', children: [{ nodeId: 'new' }] }, + treeData: [ + { + nodeId: '0', + children: [ + { nodeId: '01' }, + { nodeId: '02' }, + { nodeId: '03' }, + { nodeId: '04' }, + ], + }, + { + nodeId: '1', + expanded: true, + children: [ + { nodeId: '2' }, + { nodeId: '3', children: [{ nodeId: 'new' }] }, + ], + }, + ], + treeIndex: 4, + path: ['1', '3', 'new'], + }); + }); + + it('should work with nodes with an empty children array', () => { + expect( + insertNode( + [ + { + nodeId: '0', + expanded: true, + children: [ + { + nodeId: '1', + expanded: true, + children: [{ nodeId: '2', children: [] }], + }, + ], + }, + ], + { nodeId: 'new' }, + 2, + 2 + ) + ).toEqual({ + parentNode: { + nodeId: '1', + expanded: true, + children: [{ nodeId: 'new' }, { nodeId: '2', children: [] }], + }, + treeData: [ + { + nodeId: '0', + expanded: true, + children: [ + { + nodeId: '1', + expanded: true, + children: [{ nodeId: 'new' }, { nodeId: '2', children: [] }], + }, + ], + }, + ], + treeIndex: 2, + path: ['0', '1', 'new'], + }); + }); +}); + +describe('walk', () => { + it('should handle empty data', () => { + expect(() => + walk([], () => { + throw new Error('callback ran'); + }) + ).not.toThrow(); + }); + + it('should handle flat and nested data', () => { + [ + { + treeData: [{}], + expected: 1, + }, + { + treeData: [{}, {}], + expected: 2, + }, + { + treeData: [{}, { children: [{}] }, {}], + expected: 3, + }, + { + treeData: [{}, { children: [{}] }, {}], + ignoreCollapsed: false, + expected: 4, + }, + ].forEach(({ treeData, expected, ignoreCollapsed = true }) => { + let callCount = 0; + walk( + fill(treeData), + () => { + callCount += 1; + }, + ignoreCollapsed + ); + + expect(callCount).toEqual(expected); + }); + }); + + it('should return correct params', () => { + const paths = [['0'], ['1'], ['1', '2'], ['3']]; + let counter = 0; + + walk( + [ + { nodeId: '0' }, + { nodeId: '1', children: [{ nodeId: '2' }] }, + { nodeId: '3' }, + ], + ({ treeIndex, path }) => { + expect(treeIndex).toEqual(counter); + expect(path).toEqual(paths[treeIndex]); + counter += 1; + }, + false + ); + }); + + it('should cut walk short when false is returned', () => { + const treeData = [ + { + expanded: true, + nodeId: '0', + children: [{ nodeId: '2' }, { nodeId: '3' }], + }, + { nodeId: '6' }, + ]; + + expect(() => + walk(treeData, ({ node }) => { + if (node.nodeId === '2') { + // Cut walk short with false + return false; + } + if (node.nodeId === '3') { + throw new Error('walk not terminated by false'); + } + }) + ).not.toThrow(); + }); + + it('can get parents while walking', () => { + const treeData = [ + { + nodeId: '1', + children: [ + { nodeId: '12', children: [{ nodeId: '3' }] }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ]; + const results = []; + walk( + treeData, + ({ parentNode }) => { + results.push(parentNode ? parentNode.nodeId : null); + }, + false + ); + + expect(results).toEqual([null, '1', '12', '1', null]); + }); +}); + +describe('getTreeFromFlatData', () => { + const rootId = '-1'; + const argDefaults = { + rootId, + getNodeId: node => node.id, + getParentNodeId: node => node.parentId, + }; + + const checkFunction = ({ flatData, expected }) => { + expect( + getTreeFromFlatData( + flatData, + argDefaults.getNodeId, + argDefaults.getParentNodeId, + argDefaults.rootId + ) + ).toEqual(expected); + }; + + it('should handle empty data', () => { + [{ flatData: [], expected: [] }].forEach(checkFunction); + }); + + it('should handle [depth == 1] data', () => { + [ + { + flatData: [ + { id: '1', parentId: rootId }, + { id: '2', parentId: rootId }, + ], + expected: [ + { nodeId: '1', id: '1', parentId: rootId }, + { nodeId: '2', id: '2', parentId: rootId }, + ], + }, + { + flatData: [ + { id: '1', parentId: rootId }, + { id: '2', parentId: rootId }, + ], + expected: [ + { nodeId: '1', id: '1', parentId: rootId }, + { nodeId: '2', id: '2', parentId: rootId }, + ], + }, + ].forEach(checkFunction); + }); + + it('should handle [depth == 2] data', () => { + [ + { + flatData: [ + { id: '1', parentId: rootId }, + { id: '2', parentId: '1' }, + ], + expected: [ + { + nodeId: '1', + id: '1', + parentId: rootId, + children: [{ nodeId: '2', id: '2', parentId: '1' }], + }, + ], + }, + { + flatData: [ + { id: '1', parentId: rootId }, + { id: '2', parentId: '1' }, + ], + expected: [ + { + nodeId: '1', + id: '1', + parentId: rootId, + children: [{ nodeId: '2', id: '2', parentId: '1' }], + }, + ], + }, + ].forEach(checkFunction); + }); + + it('should handle [depth > 2] nested data', () => { + [ + { + flatData: [ + { id: '3', parentId: '2' }, + { id: '1', parentId: rootId }, + { id: '2', parentId: '1' }, + ], + expected: [ + { + nodeId: '1', + id: '1', + parentId: rootId, + children: [ + { + nodeId: '2', + id: '2', + parentId: '1', + children: [{ nodeId: '3', id: '3', parentId: '2' }], + }, + ], + }, + ], + }, + { + flatData: [ + { id: '4', parentId: '2' }, + { id: '3', parentId: '2' }, + { id: '7', parentId: rootId }, + { id: '1', parentId: rootId }, + { id: '2', parentId: '1' }, + { id: '6', parentId: '1' }, + ], + expected: [ + { nodeId: '7', id: '7', parentId: rootId }, + { + nodeId: '1', + id: '1', + parentId: rootId, + children: [ + { + nodeId: '2', + id: '2', + parentId: '1', + children: [ + { nodeId: '4', id: '4', parentId: '2' }, + { nodeId: '3', id: '3', parentId: '2' }, + ], + }, + { nodeId: '6', id: '6', parentId: '1' }, + ], + }, + ], + }, + ].forEach(checkFunction); + }); +}); + +describe('map', () => { + const checkFunction = ({ treeData, callback, ignoreCollapsed, expected }) => { + expect(map(treeData, callback, ignoreCollapsed)).toEqual(expected); + }; + + it('should handle empty data', () => { + [ + { + treeData: [], + callback: ({ node }) => node, + expected: [], + }, + { + treeData: null, + callback: ({ node }) => node, + expected: [], + }, + { + treeData: undefined, + callback: ({ node }) => node, + expected: [], + }, + ].forEach(checkFunction); + }); + + it('can return tree as-is', () => { + [ + { + callback: ({ node }) => node, + treeData: [{ nodeId: '1' }, { nodeId: '2' }], + expected: [{ nodeId: '1' }, { nodeId: '2' }], + }, + { + callback: ({ node }) => node, + treeData: [{ nodeId: '1', children: [{ nodeId: '2' }] }], + expected: [{ nodeId: '1', children: [{ nodeId: '2' }] }], + }, + { + callback: ({ node }) => node, + treeData: [ + { + nodeId: '1', + children: [ + { nodeId: '12', children: [{ nodeId: '3' }] }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + expected: [ + { + nodeId: '1', + children: [ + { nodeId: '12', children: [{ nodeId: '3' }] }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + }, + ].forEach(checkFunction); + }); + + it('can truncate part of the tree', () => { + [ + { + callback: ({ node }) => + node.nodeId === '1' ? { ...node, children: [] } : node, + treeData: [ + { + nodeId: '1', + children: [ + { nodeId: '12', children: [{ nodeId: '3' }] }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + expected: [{ nodeId: '1', children: [] }, { nodeId: '5' }], + }, + ].forEach(checkFunction); + }); + + it('can get parents', () => { + checkFunction({ + callback: ({ node, parentNode }) => ({ + ...node, + parentId: parentNode ? parentNode.nodeId : null, + }), + ignoreCollapsed: false, + treeData: [ + { + nodeId: '1', + children: [ + { + nodeId: '12', + children: [{ nodeId: '3' }], + }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + expected: [ + { + nodeId: '1', + parentId: null, + children: [ + { + nodeId: '12', + parentId: '1', + children: [ + { + nodeId: '3', + parentId: '12', + }, + ], + }, + { + nodeId: '4', + parentId: '1', + }, + ], + }, + { + nodeId: '5', + parentId: null, + }, + ], + }); + }); + + it('can sort part of the tree', () => { + [ + { + callback: ({ node }) => + !node.children + ? node + : { + ...node, + children: node.children.sort((a, b) => a.nodeId - b.nodeId), + }, + treeData: [ + { + nodeId: '1', + expanded: true, + children: [ + { + nodeId: '12', + expanded: true, + children: [{ nodeId: '33' }, { nodeId: '3' }], + }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + expected: [ + { + nodeId: '1', + expanded: true, + children: [ + { nodeId: '4' }, + { + nodeId: '12', + expanded: true, + children: [{ nodeId: '3' }, { nodeId: '33' }], + }, + ], + }, + { nodeId: '5' }, + ], + }, + ].forEach(checkFunction); + }); + + it('can modify every node in the tree', () => { + [ + { + callback: ({ node }) => ({ ...node, expanded: true }), + ignoreCollapsed: false, + treeData: [ + { + nodeId: '1', + children: [ + { + nodeId: '12', + children: [{ nodeId: '33' }, { nodeId: '3' }], + }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ], + expected: [ + { + nodeId: '1', + expanded: true, + children: [ + { + nodeId: '12', + expanded: true, + children: [ + { nodeId: '33', expanded: true }, + { nodeId: '3', expanded: true }, + ], + }, + { nodeId: '4', expanded: true }, + ], + }, + { nodeId: '5', expanded: true }, + ], + }, + ].forEach(checkFunction); + }); +}); + +describe('isDescendant', () => { + const treeData = [ + { + nodeId: '1', + children: [ + { + nodeId: '12', + children: [{ nodeId: '33' }, { nodeId: '3' }], + }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ]; + + it('should work at the base', () => { + expect(isDescendant(treeData[0], treeData[0])).toEqual(false); + expect(isDescendant(treeData[0], treeData[1])).toEqual(false); + expect(isDescendant(treeData[0], treeData[0].children[1])).toEqual(true); + }); + + it('should work deeper in the tree', () => { + expect( + isDescendant(treeData[0].children[0], treeData[0].children[0].children[1]) + ).toEqual(true); + }); +}); + +describe('getDepth', () => { + const treeData = [ + { + nodeId: '1', + children: [ + { + nodeId: '12', + children: [{ nodeId: '33' }, { nodeId: '3' }], + }, + { nodeId: '4' }, + ], + }, + { nodeId: '5' }, + ]; + + it('should work at the base', () => { + expect(getDepth(treeData[0])).toEqual(2); + expect(getDepth(treeData[1])).toEqual(0); + }); + + it('should work deeper in the tree', () => { + expect(getDepth(treeData[0].children[0])).toEqual(1); + }); +}); + +describe('getDescendantCount', () => { + it('should count flat data', () => { + expect(getDescendantCount(fill({}), false)).toEqual(0); + expect(getDescendantCount(fill({ children: [] }), false)).toEqual(0); + expect(getDescendantCount(fill({ children: [{}] }), false)).toEqual(1); + expect(getDescendantCount(fill({ children: [{}, {}] }), false)).toEqual(2); + }); + + it('should count nested data', () => { + const nested = fill({ + expanded: true, + children: [{}, { children: [{}] }, {}], + }); + + expect(getDescendantCount(nested, false)).toEqual(4); + expect(getDescendantCount(nested, true)).toEqual(3); + }); +}); + +describe('find', () => { + const offsetDefault = 0; + const check = (treeData: TreeData, searchFocusOffset = offsetDefault) => { + return find( + treeData, + '42', + ({ node, searchQuery }) => node.nodeId === searchQuery, + searchFocusOffset, + false, + true + ); + }; + + it('should work with flat data', () => { + let result: ReturnType; + + result = check(fill([{}])); + expect(result.matches).toEqual([]); + + result = check([{ nodeId: '41' }]); + expect(result.matches).toEqual([]); + + result = check([{ nodeId: '42' }]); + expect(result.matches).toEqual([ + { node: { nodeId: '42' }, treeIndex: 0, path: ['42'] }, + ]); + expect(result.matches[offsetDefault].treeIndex).toEqual(0); + + result = check([{ nodeId: '41' }, { nodeId: '42' }]); + expect(result.matches).toEqual([ + { node: { nodeId: '42' }, treeIndex: 1, path: ['42'] }, + ]); + expect(result.matches[offsetDefault].treeIndex).toEqual(1); + + result = check([{ nodeId: '42' }, { nodeId: '42' }]); + expect(result.matches).toEqual([ + { node: { nodeId: '42' }, treeIndex: 0, path: ['42'] }, + { node: { nodeId: '42' }, treeIndex: 1, path: ['42'] }, + ]); + expect(result.matches[offsetDefault].treeIndex).toEqual(0); + + result = check( + [ + { nodeId: '1' }, + { nodeId: '42' }, + { nodeId: '3' }, + { nodeId: '3' }, + { nodeId: '3' }, + { nodeId: '4' }, + { nodeId: '42' }, + { nodeId: '42' }, + { nodeId: '4' }, + { nodeId: '42' }, + ], + 3 + ); + expect(result.matches).toEqual([ + { node: { nodeId: '42' }, treeIndex: 1, path: ['42'] }, + { node: { nodeId: '42' }, treeIndex: 6, path: ['42'] }, + { node: { nodeId: '42' }, treeIndex: 7, path: ['42'] }, + { node: { nodeId: '42' }, treeIndex: 9, path: ['42'] }, + ]); + expect(result.matches[3].treeIndex).toEqual(9); + }); + + it('should work with nested data', () => { + let result: ReturnType; + + result = check(fill([{ children: [{ nodeId: '42' }] }])); + expect(result.matches.length).toEqual(1); + expect(result.matches[offsetDefault].treeIndex).toEqual(1); + + result = check( + fill([{ children: [{ nodeId: '41' }] }, { children: [{ nodeId: '42' }] }]) + ); + expect(result.matches.length).toEqual(1); + expect(result.matches[offsetDefault].treeIndex).toEqual(2); + expect(result.treeData).toEqual( + fill([ + { children: [{ nodeId: '41' }] }, + { expanded: true, children: [{ nodeId: '42' }] }, + ]) + ); + + result = check(fill([{ children: [{ children: [{ nodeId: '42' }] }] }])); + expect(result.matches.length).toEqual(1); + expect(result.matches[offsetDefault].treeIndex).toEqual(2); + + result = check( + fill([{ children: [{ nodeId: '42', children: [{ nodeId: '42' }] }] }]) + ); + expect(result.matches.length).toEqual(2); + expect(result.matches[offsetDefault].treeIndex).toEqual(1); + + result = check( + fill([ + {}, + { + children: [ + { nodeId: '42', expanded: true, children: [{ nodeId: '42' }] }, + ], + }, + ]) + ); + expect(result.matches.length).toEqual(2); + expect(result.matches[offsetDefault].treeIndex).toEqual(2); + + result = check( + fill([ + {}, + { + children: [ + { nodeId: '1', expanded: true, children: [{ nodeId: '1' }] }, + ], + }, + ]) + ); + expect(result.matches.length).toEqual(0); + }); +}); + +describe('toggleExpandedForAll', () => { + it('should expand all', () => { + expect( + toggleExpandedForAll( + [ + { + nodeId: '0', + children: [{ nodeId: '1', children: [{ nodeId: '2' }] }], + }, + ], + true + ) + ).toEqual([ + { + nodeId: '0', + expanded: true, + children: [ + { + nodeId: '1', + expanded: true, + children: [{ expanded: true, nodeId: '2' }], + }, + ], + }, + ]); + }); + it('should collapse all', () => { + expect( + toggleExpandedForAll( + [ + { + expanded: true, + nodeId: '0', + children: [ + { + nodeId: '1', + expanded: true, + children: [{ nodeId: '2', expanded: true }], + }, + ], + }, + ], + false + ) + ).toEqual([ + { + expanded: false, + nodeId: '0', + children: [ + { + nodeId: '1', + expanded: false, + children: [{ nodeId: '2', expanded: false }], + }, + ], + }, + ]); + }); +}); diff --git a/src/utils/tree-data-utils.test.js b/src/utils/tree-data-utils.test.js deleted file mode 100644 index 68128a3c..00000000 --- a/src/utils/tree-data-utils.test.js +++ /dev/null @@ -1,2244 +0,0 @@ -import { - getVisibleNodeCount, - getVisibleNodeInfoAtIndex, - changeNodeAtPath, - addNodeUnderParent, - getTreeFromFlatData, - getNodeAtPath, - getFlatDataFromTree, - walk, - map, - insertNode, - isDescendant, - getDepth, - getDescendantCount, - find, - toggleExpandedForAll, -} from './tree-data-utils'; - -const keyFromTreeIndex = ({ treeIndex }) => treeIndex; -const keyFromKey = ({ node }) => node.key; - -describe('getVisibleNodeCount', () => { - it('should handle flat data', () => { - expect( - getVisibleNodeCount({ - treeData: [{}, {}], - }) - ).toEqual(2); - }); - - it('should handle hidden nested data', () => { - expect( - getVisibleNodeCount({ - treeData: [ - { - children: [ - { - children: [{}, {}], - }, - { - children: [{}], - }, - ], - }, - {}, - ], - }) - ).toEqual(2); - }); - - it('should handle functions', () => { - expect( - getVisibleNodeCount({ - treeData: [ - { - expanded: true, - children: [ - { - expanded: true, - children: [ - { - expanded: true, - children: () => ({ key: 1 }), - }, - {}, - ], - }, - { - children: [{}], - }, - ], - }, - {}, - ], - }) - ).toEqual(6); - }); - - it('should handle partially expanded nested data', () => { - expect( - getVisibleNodeCount({ - treeData: [ - { - expanded: true, - children: [ - { - expanded: true, - children: [{}, {}], - }, - { - children: [{}], - }, - ], - }, - {}, - ], - }) - ).toEqual(6); - }); - - it('should handle fully expanded nested data', () => { - expect( - getVisibleNodeCount({ - treeData: [ - { - expanded: true, - children: [ - { - expanded: true, - children: [{}, {}], - }, - { - expanded: true, - children: [{}], - }, - ], - }, - {}, - ], - }) - ).toEqual(7); - }); -}); - -describe('getVisibleNodeInfoAtIndex', () => { - it('should handle empty data', () => { - expect( - getVisibleNodeInfoAtIndex({ - treeData: [], - index: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - expect( - getVisibleNodeInfoAtIndex({ - treeData: null, - index: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - expect( - getVisibleNodeInfoAtIndex({ - treeData: undefined, - index: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - }); - - it('should handle flat data', () => { - expect( - getVisibleNodeInfoAtIndex({ - treeData: [{ key: 0 }], - index: 0, - getNodeKey: keyFromTreeIndex, - }).node.key - ).toEqual(0); - expect( - getVisibleNodeInfoAtIndex({ - treeData: [{ key: 0 }, { key: 1 }], - index: 1, - getNodeKey: keyFromTreeIndex, - }).node.key - ).toEqual(1); - }); - - it('should handle hidden nested data', () => { - const result = getVisibleNodeInfoAtIndex({ - treeData: [ - { - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - index: 1, - getNodeKey: keyFromTreeIndex, - }); - - expect(result.node.key).toEqual(6); - expect(result.path).toEqual([1]); - expect(result.lowerSiblingCounts).toEqual([0]); - }); - - it('should handle partially expanded nested data', () => { - const result = getVisibleNodeInfoAtIndex({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - index: 3, - getNodeKey: keyFromKey, - }); - - expect(result.node.key).toEqual(5); - expect(result.path).toEqual([0, 4, 5]); - expect(result.lowerSiblingCounts).toEqual([1, 0, 0]); - }); - - it('should handle fully expanded nested data', () => { - const result = getVisibleNodeInfoAtIndex({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - index: 5, - getNodeKey: keyFromTreeIndex, - }); - - expect(result.node.key).toEqual(5); - expect(result.path).toEqual([0, 4, 5]); - expect(result.lowerSiblingCounts).toEqual([1, 0, 0]); - }); - - it('should handle an index that is larger than the data', () => { - expect( - getVisibleNodeInfoAtIndex({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - index: 7, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - }); -}); - -describe('getNodeAtPath', () => { - it('should handle empty data', () => { - expect( - getNodeAtPath({ - treeData: [], - path: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - expect( - getNodeAtPath({ - treeData: null, - path: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - expect( - getNodeAtPath({ - treeData: undefined, - path: 1, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - }); - - it('should handle flat data', () => { - expect( - getNodeAtPath({ - treeData: [{ key: 0 }], - path: [0], - getNodeKey: keyFromTreeIndex, - }).node.key - ).toEqual(0); - expect( - getNodeAtPath({ - treeData: [{ key: 0 }, { key: 1 }], - path: [1], - getNodeKey: keyFromTreeIndex, - }).node.key - ).toEqual(1); - }); - - it('should handle hidden nested data', () => { - const result = getNodeAtPath({ - treeData: [ - { - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - path: [1], - getNodeKey: keyFromTreeIndex, - }); - - expect(result.node.key).toEqual(6); - }); - - it('should handle partially expanded nested data', () => { - const result = getNodeAtPath({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - path: [0, 4, 5], - getNodeKey: keyFromKey, - }); - - expect(result.node.key).toEqual(5); - }); - - it('should handle fully expanded nested data', () => { - const result = getNodeAtPath({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - path: [0, 4, 5], - getNodeKey: keyFromTreeIndex, - }); - - expect(result.node.key).toEqual(5); - }); - - it('should handle an index that is larger than the data', () => { - expect( - getNodeAtPath({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ], - path: [7], - getNodeKey: keyFromTreeIndex, - }) - ).toEqual(null); - }); -}); - -describe('getFlatDataFromTree', () => { - it('should handle empty data', () => { - expect( - getFlatDataFromTree({ treeData: [], getNodeKey: keyFromTreeIndex }) - ).toEqual([]); - expect( - getFlatDataFromTree({ treeData: null, getNodeKey: keyFromTreeIndex }) - ).toEqual([]); - expect( - getFlatDataFromTree({ treeData: undefined, getNodeKey: keyFromTreeIndex }) - ).toEqual([]); - }); - - it('should handle flat data', () => { - expect( - getFlatDataFromTree({ - ignoreCollapsed: true, - getNodeKey: keyFromTreeIndex, - treeData: [{ key: 0 }], - }) - ).toEqual([ - { - node: { key: 0 }, - parentNode: null, - path: [0], - lowerSiblingCounts: [0], - treeIndex: 0, - }, - ]); - - expect( - getFlatDataFromTree({ - ignoreCollapsed: true, - treeData: [{ key: 0 }, { key: 1 }], - getNodeKey: keyFromTreeIndex, - }) - ).toEqual([ - { - node: { key: 0 }, - parentNode: null, - path: [0], - lowerSiblingCounts: [1], - treeIndex: 0, - }, - { - node: { key: 1 }, - parentNode: null, - path: [1], - lowerSiblingCounts: [0], - treeIndex: 1, - }, - ]); - }); - - it('should handle hidden nested data', () => { - const treeData = [ - { - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ]; - - expect( - getFlatDataFromTree({ - ignoreCollapsed: true, - getNodeKey: keyFromTreeIndex, - treeData, - }) - ).toEqual([ - { - node: treeData[0], - parentNode: null, - path: [0], - lowerSiblingCounts: [1], - treeIndex: 0, - }, - { - node: treeData[1], - parentNode: null, - path: [1], - lowerSiblingCounts: [0], - treeIndex: 1, - }, - ]); - }); - - it('should handle partially expanded nested data', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ]; - - expect( - getFlatDataFromTree({ - ignoreCollapsed: true, - getNodeKey: keyFromKey, - treeData, - }) - ).toEqual([ - { - node: treeData[0], - parentNode: null, - path: [0], - lowerSiblingCounts: [1], - treeIndex: 0, - }, - { - node: treeData[0].children[0], - parentNode: treeData[0], - path: [0, 1], - lowerSiblingCounts: [1, 1], - treeIndex: 1, - }, - { - node: treeData[0].children[1], - parentNode: treeData[0], - path: [0, 4], - lowerSiblingCounts: [1, 0], - treeIndex: 2, - }, - { - node: treeData[0].children[1].children[0], - parentNode: treeData[0].children[1], - path: [0, 4, 5], - lowerSiblingCounts: [1, 0, 0], - treeIndex: 3, - }, - { - node: treeData[1], - parentNode: null, - path: [6], - lowerSiblingCounts: [0], - treeIndex: 4, - }, - ]); - }); - - it('should handle fully expanded nested data', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - { - expanded: true, - key: 4, - children: [{ key: 5 }], - }, - ], - }, - { key: 6 }, - ]; - - expect( - getFlatDataFromTree({ - ignoreCollapsed: true, - treeData, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual([ - { - node: treeData[0], - parentNode: null, - path: [0], - lowerSiblingCounts: [1], - treeIndex: 0, - }, - { - node: treeData[0].children[0], - parentNode: treeData[0], - path: [0, 1], - lowerSiblingCounts: [1, 1], - treeIndex: 1, - }, - { - node: treeData[0].children[0].children[0], - parentNode: treeData[0].children[0], - path: [0, 1, 2], - lowerSiblingCounts: [1, 1, 1], - treeIndex: 2, - }, - { - node: treeData[0].children[0].children[1], - parentNode: treeData[0].children[0], - path: [0, 1, 3], - lowerSiblingCounts: [1, 1, 0], - treeIndex: 3, - }, - { - node: treeData[0].children[1], - parentNode: treeData[0], - path: [0, 4], - lowerSiblingCounts: [1, 0], - treeIndex: 4, - }, - { - node: treeData[0].children[1].children[0], - parentNode: treeData[0].children[1], - path: [0, 4, 5], - lowerSiblingCounts: [1, 0, 0], - treeIndex: 5, - }, - { - node: treeData[1], - parentNode: null, - path: [6], - lowerSiblingCounts: [0], - treeIndex: 6, - }, - ]); - }); -}); - -describe('changeNodeAtPath', () => { - it('should handle empty data', () => { - const noChildrenError = new Error( - 'Path referenced children of node with no children.' - ); - const noNodeError = new Error('No node found at the given path.'); - expect(() => - changeNodeAtPath({ - treeData: [], - path: [1], - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toThrow(noNodeError); - expect(() => - changeNodeAtPath({ - treeData: null, - path: [1], - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toThrow(noChildrenError); - expect(() => - changeNodeAtPath({ - treeData: null, - path: [1, 2], - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toThrow(noChildrenError); - expect(() => - changeNodeAtPath({ - treeData: undefined, - path: [1], - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toThrow(noChildrenError); - }); - - it('should handle flat data', () => { - expect( - changeNodeAtPath({ - treeData: [{ key: 0 }], - path: [0], - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual([{ key: 1 }]); - - expect( - changeNodeAtPath({ - treeData: [{ key: 0 }, { key: 'a' }], - path: ['a'], - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual([{ key: 0 }, { key: 1 }]); - }); - - it('should handle nested data', () => { - const result = changeNodeAtPath({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - key: 'b', - children: [{ key: 2 }, { key: 3 }, { key: 'f' }], - }, - { - expanded: true, - key: 'r', - children: [{ key: 5 }, { key: 8 }, { key: 7 }], - }, - ], - }, - { key: 6 }, - ], - path: [0, 2, 5], - newNode: { food: 'pancake' }, - getNodeKey: keyFromTreeIndex, - }); - - expect(result[0].children[1].children[2].food).toEqual('pancake'); - }); - - it('should handle adding children', () => { - const result = changeNodeAtPath({ - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - key: 'b', - children: [{ key: 2 }, { key: 3 }, { key: 'f' }], - }, - { - expanded: true, - key: 'r', - children: [{ key: 5 }, { key: 8 }, { key: 7 }], - }, - ], - }, - { key: 6 }, - ], - path: [0, 2, 5], - newNode: ({ node }) => ({ - ...node, - children: [{ food: 'pancake' }], - }), - getNodeKey: keyFromTreeIndex, - }); - - expect(result[0].children[1].children[2].children[0].food).toEqual( - 'pancake' - ); - }); - - it('should handle adding children to the root', () => { - expect( - changeNodeAtPath({ - treeData: [], - path: [], - newNode: ({ node }) => ({ - ...node, - children: [...node.children, { key: 1 }], - }), - getNodeKey: keyFromKey, - }) - ).toEqual([{ key: 1 }]); - - expect( - changeNodeAtPath({ - treeData: [{ key: 0 }], - path: [], - newNode: ({ node }) => ({ - ...node, - children: [...node.children, { key: 1 }], - }), - getNodeKey: keyFromKey, - }) - ).toEqual([{ key: 0 }, { key: 1 }]); - }); - - it('should delete data when falsey node passed', () => { - const result = changeNodeAtPath({ - treeData: [ - { - expanded: true, - key: 'b', - children: [{ key: 'f' }], - }, - { - expanded: true, - key: 'r', - children: [{ key: 7 }], - }, - { key: 6 }, - ], - path: [2, 3], - newNode: null, - getNodeKey: keyFromTreeIndex, - }); - - expect(result[1].children.length).toEqual(0); - }); - - it('should delete data on the top level', () => { - const treeData = [ - { - expanded: true, - key: 'b', - children: [{ key: 'f' }], - }, - { - expanded: true, - key: 'r', - children: [{ key: 7 }], - }, - { key: 6 }, - ]; - const result = changeNodeAtPath({ - treeData, - path: [2], - newNode: null, - getNodeKey: keyFromTreeIndex, - }); - - expect(result).toEqual([treeData[0], treeData[2]]); - }); - - it('should handle a path that is too long', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - ], - }, - ]; - - expect(() => - changeNodeAtPath({ - treeData, - path: [0, 1, 2, 4], - newNode: { a: 1 }, - getNodeKey: keyFromKey, - }) - ).toThrow(new Error('Path referenced children of node with no children.')); - }); - - it('should handle a path that does not exist', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - ], - }, - ]; - - expect(() => - changeNodeAtPath({ - treeData, - path: [0, 2], - newNode: { a: 1 }, - getNodeKey: keyFromKey, - }) - ).toThrowError('No node found at the given path.'); - }); -}); - -describe('addNodeUnderParent', () => { - it('should handle empty data', () => { - expect( - addNodeUnderParent({ - treeData: [], - parentKey: null, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ treeData: [{}], treeIndex: 0 }); - expect( - addNodeUnderParent({ - treeData: null, - parentKey: null, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ treeData: [{}], treeIndex: 0 }); - expect( - addNodeUnderParent({ - treeData: undefined, - parentKey: null, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ treeData: [{}], treeIndex: 0 }); - }); - - it('should handle a parentPath that does not exist', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - ], - }, - ]; - - expect(() => - addNodeUnderParent({ - treeData, - parentKey: 'fake', - newNode: { a: 1 }, - getNodeKey: keyFromKey, - }) - ).toThrowError('No node found with the given key.'); - }); - - it('should handle flat data', () => { - // Older sibling of only node - expect( - addNodeUnderParent({ - treeData: [{ key: 0 }], - parentKey: null, - newNode: { key: 1 }, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ treeData: [{ key: 0 }, { key: 1 }], treeIndex: 1 }); - - // Child of only node - expect( - addNodeUnderParent({ - treeData: [{ key: 0 }], - parentKey: 0, - newNode: { key: 1 }, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ treeData: [{ key: 0, children: [{ key: 1 }] }], treeIndex: 1 }); - - expect( - addNodeUnderParent({ - treeData: [{ key: 0 }, { key: 'a' }], - parentKey: 'a', - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual({ - treeData: [{ key: 0 }, { key: 'a', children: [{ key: 1 }] }], - treeIndex: 2, - }); - }); - - // Tree looks like this - // /\ - // 0 6 - // / \ - // 1 5 - // / \ - // 2 3 - // \ - // 4 - const nestedParams = { - treeData: [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [ - { key: 2 }, - { - expanded: false, - key: 3, - children: [{ key: 4 }], - }, - ], - }, - { key: 5 }, - ], - }, - { key: 6 }, - ], - newNode: { key: 'new' }, - }; - - it('should handle nested data #1', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 0, - getNodeKey: keyFromKey, - }); - - expect(result.treeData[0].children[2]).toEqual(nestedParams.newNode); - expect(result.treeIndex).toEqual(5); - }); - - it('should handle nested data #2', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 1, - getNodeKey: keyFromKey, - }); - - expect(result.treeData[0].children[0].children[2]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(4); - }); - - it('should handle nested data #3', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 3, - getNodeKey: keyFromKey, - }); - - expect(result.treeData[0].children[0].children[1].children[1]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(5); - }); - - it('should handle nested data #1 (using tree index as key)', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 0, - getNodeKey: keyFromTreeIndex, - }); - - expect(result.treeData[0].children[2]).toEqual(nestedParams.newNode); - expect(result.treeIndex).toEqual(5); - }); - - it('should handle nested data #2 (using tree index as key)', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 1, - getNodeKey: keyFromTreeIndex, - }); - - expect(result.treeData[0].children[0].children[2]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(4); - }); - - it('should handle nested data #3 (using tree index as key)', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 3, - getNodeKey: keyFromTreeIndex, - }); - - expect(result.treeData[0].children[0].children[1].children[1]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(5); - }); - - it('should add new node as last child by default', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 0, - getNodeKey: keyFromKey, - }); - - const [ - existingChild0, - existingChild1, - expectedNewNode, - ] = result.treeData[0].children; - - expect(expectedNewNode).toEqual(nestedParams.newNode); - expect([existingChild0, existingChild1]).toEqual( - nestedParams.treeData[0].children - ); - }); - - it('should add new node as first child if addAsFirstChild is true', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: 0, - getNodeKey: keyFromKey, - addAsFirstChild: true, - }); - - const [expectedNewNode, ...previousChildren] = result.treeData[0].children; - - expect(expectedNewNode).toEqual(nestedParams.newNode); - expect(previousChildren).toEqual(nestedParams.treeData[0].children); - }); - - it('should add new node as first child under root if addAsFirstChild is true', () => { - const result = addNodeUnderParent({ - ...nestedParams, - parentKey: null, - getNodeKey: keyFromKey, - addAsFirstChild: true, - }); - - const [expectedNewNode, ...previousTreeData] = result.treeData; - - expect(expectedNewNode).toEqual(nestedParams.newNode); - expect(previousTreeData).toEqual(nestedParams.treeData); - }); -}); - -describe('insertNode', () => { - it('should handle empty data', () => { - expect( - insertNode({ - treeData: [], - depth: 0, - minimumTreeIndex: 0, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ parentNode: null, treeData: [{}], treeIndex: 0, path: [0] }); - expect( - insertNode({ - treeData: null, - depth: 0, - minimumTreeIndex: 0, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ parentNode: null, treeData: [{}], treeIndex: 0, path: [0] }); - expect( - insertNode({ - treeData: undefined, - depth: 0, - minimumTreeIndex: 0, - newNode: {}, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ parentNode: null, treeData: [{}], treeIndex: 0, path: [0] }); - }); - - it('should handle a depth that is deeper than any branch in the tree', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - ], - }, - ]; - - expect( - insertNode({ - treeData, - depth: 4, - minimumTreeIndex: 0, - newNode: { key: 'new' }, - getNodeKey: keyFromKey, - }).treeData[0] - ).toEqual({ key: 'new' }); - }); - - it('should handle a minimumTreeIndex that is too big', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [ - { - expanded: true, - key: 1, - children: [{ key: 2 }, { key: 3 }], - }, - ], - }, - { key: 4 }, - ]; - - let insertResult = insertNode({ - treeData, - depth: 0, - minimumTreeIndex: 15, - newNode: { key: 'new' }, - getNodeKey: keyFromKey, - }); - expect(insertResult.treeData[2]).toEqual({ key: 'new' }); - expect(insertResult.treeIndex).toEqual(5); - expect(insertResult.path).toEqual(['new']); - - insertResult = insertNode({ - treeData, - depth: 2, - minimumTreeIndex: 15, - newNode: { key: 'new' }, - getNodeKey: keyFromKey, - }); - - expect(insertResult.treeData[1].children[0]).toEqual({ key: 'new' }); - expect(insertResult.treeIndex).toEqual(5); - expect(insertResult.path).toEqual([4, 'new']); - }); - - it('should handle flat data (before)', () => { - expect( - insertNode({ - treeData: [{ key: 0 }], - depth: 0, - minimumTreeIndex: 0, - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual({ - parentNode: null, - treeData: [{ key: 1 }, { key: 0 }], - treeIndex: 0, - path: [1], - }); - }); - - it('should handle flat data (after)', () => { - expect( - insertNode({ - treeData: [{ key: 0 }], - depth: 0, - minimumTreeIndex: 1, - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual({ - parentNode: null, - treeData: [{ key: 0 }, { key: 1 }], - treeIndex: 1, - path: [1], - }); - }); - - it('should handle flat data (child)', () => { - expect( - insertNode({ - treeData: [{ key: 0 }], - depth: 1, - minimumTreeIndex: 1, - newNode: { key: 1 }, - getNodeKey: keyFromKey, - }) - ).toEqual({ - parentNode: { key: 0, children: [{ key: 1 }] }, - treeData: [{ key: 0, children: [{ key: 1 }] }], - treeIndex: 1, - path: [0, 1], - }); - }); - - // Tree looks like this - // /\ - // 0 6 - // / \ - // 1 5 - // / \ - // 2 3 - // \ - // 4 - const nestedParams = { - treeData: [ - // Depth 0 - { - expanded: true, - key: 0, - children: [ - // Depth 1 - { - expanded: true, - key: 1, - children: [ - // Depth 2 - { key: 2 }, - { - expanded: false, - key: 3, - children: [ - // Depth 3 - { key: 4 }, - ], - }, - { key: 5 }, - ], - }, - { key: 6 }, - ], - }, - { key: 7 }, - ], - newNode: { key: 'new' }, - getNodeKey: keyFromKey, - }; - - it('should handle nested data #1', () => { - const result = insertNode({ - ...nestedParams, - depth: 1, - minimumTreeIndex: 4, - }); - - expect(result.treeData[0].children[1]).toEqual(nestedParams.newNode); - expect(result.treeIndex).toEqual(5); - expect(result.path).toEqual([0, 'new']); - }); - - it('should handle nested data #2', () => { - let result = insertNode({ - ...nestedParams, - depth: 2, - ignoreCollapsed: true, - minimumTreeIndex: 5, - }); - - expect(result.treeData[0].children[0].children[3]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(5); - expect(result.path).toEqual([0, 1, 'new']); - - result = insertNode({ - ...nestedParams, - depth: 2, - ignoreCollapsed: false, - minimumTreeIndex: 5, - }); - - expect(result.treeData[0].children[0].children[2]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(5); - expect(result.path).toEqual([0, 1, 'new']); - }); - - it('should handle nested data #3', () => { - const result = insertNode({ - ...nestedParams, - depth: 3, - minimumTreeIndex: 3, - }); - - expect(result.treeData[0].children[0].children[0].children[0]).toEqual( - nestedParams.newNode - ); - expect(result.treeIndex).toEqual(3); - expect(result.path).toEqual([0, 1, 2, 'new']); - }); - - it('should handle nested data #4', () => { - expect( - insertNode({ - treeData: [ - { key: 0, expanded: true, children: [{ key: 1 }] }, - { key: 2 }, - ], - newNode: { key: 'new' }, - depth: 1, - minimumTreeIndex: 3, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ - parentNode: { key: 2, children: [{ key: 'new' }] }, - treeData: [ - { key: 0, expanded: true, children: [{ key: 1 }] }, - { key: 2, children: [{ key: 'new' }] }, - ], - treeIndex: 3, - path: [2, 3], - }); - }); - - it('should work with a preceding node with children #1', () => { - expect( - insertNode({ - treeData: [{ children: [{}] }, { expanded: true, children: [{}, {}] }], - newNode: { key: 'new' }, - depth: 1, - minimumTreeIndex: 3, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ - parentNode: { expanded: true, children: [{}, { key: 'new' }, {}] }, - treeData: [ - { children: [{}] }, - { expanded: true, children: [{}, { key: 'new' }, {}] }, - ], - treeIndex: 3, - path: [1, 3], - }); - }); - - it('should work with a preceding node with children #2', () => { - expect( - insertNode({ - treeData: [{ children: [{}] }, { expanded: true, children: [{}, {}] }], - newNode: { key: 'new' }, - depth: 2, - minimumTreeIndex: 4, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ - parentNode: { children: [{ key: 'new' }] }, - treeData: [ - { children: [{}] }, - { expanded: true, children: [{}, { children: [{ key: 'new' }] }] }, - ], - treeIndex: 4, - path: [1, 3, 4], - }); - }); - - it('should work with a preceding node with children #3', () => { - expect( - insertNode({ - treeData: [ - { children: [{}, {}, {}, {}] }, - { expanded: true, children: [{}, {}] }, - ], - newNode: { key: 'new' }, - depth: 2, - minimumTreeIndex: 4, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ - parentNode: { children: [{ key: 'new' }] }, - treeData: [ - { children: [{}, {}, {}, {}] }, - { expanded: true, children: [{}, { children: [{ key: 'new' }] }] }, - ], - treeIndex: 4, - path: [1, 3, 4], - }); - }); - - it('should work with nodes with an empty children array', () => { - expect( - insertNode({ - treeData: [ - { - expanded: true, - children: [ - { - expanded: true, - children: [{ children: [] }], - }, - ], - }, - ], - newNode: { key: 'new' }, - depth: 2, - minimumTreeIndex: 2, - getNodeKey: keyFromTreeIndex, - }) - ).toEqual({ - parentNode: { - expanded: true, - children: [{ key: 'new' }, { children: [] }], - }, - treeData: [ - { - expanded: true, - children: [ - { - expanded: true, - children: [{ key: 'new' }, { children: [] }], - }, - ], - }, - ], - treeIndex: 2, - path: [0, 1, 2], - }); - }); -}); - -describe('walk', () => { - it('should handle empty data', () => { - [[], null, undefined].forEach(treeData => { - expect(() => - walk({ - treeData, - getNodeKey: keyFromTreeIndex, - callback: () => { - throw new Error('callback ran'); - }, - }) - ).not.toThrow(); - }); - }); - - it('should handle flat and nested data', () => { - [ - { - treeData: [{}], - expected: 1, - }, - { - treeData: [{}, {}], - expected: 2, - }, - { - treeData: [{}, { children: [{}] }, {}], - expected: 3, - }, - { - treeData: [{}, { children: [{}] }, {}], - ignoreCollapsed: false, - expected: 4, - }, - ].forEach(({ treeData, expected, ignoreCollapsed = true }) => { - let callCount = 0; - walk({ - treeData, - ignoreCollapsed, - getNodeKey: keyFromTreeIndex, - callback: () => { - callCount += 1; - }, - }); - - expect(callCount).toEqual(expected); - }); - }); - - it('should return correct params', () => { - const paths = [[0], [1], [1, 2], [3]]; - let counter = 0; - - walk({ - treeData: [{}, { children: [{}] }, {}], - ignoreCollapsed: false, - getNodeKey: keyFromTreeIndex, - callback: ({ treeIndex, path }) => { - expect(treeIndex).toEqual(counter); - expect(path).toEqual(paths[treeIndex]); - counter += 1; - }, - }); - }); - - it('should cut walk short when false is returned', () => { - const treeData = [ - { - expanded: true, - key: 0, - children: [{ key: 2 }, { key: 3 }], - }, - { key: 6 }, - ]; - - expect(() => - walk({ - treeData, - getNodeKey: keyFromTreeIndex, - callback: ({ node }) => { - if (node.key === 2) { - // Cut walk short with false - return false; - } - if (node.key === 3) { - throw new Error('walk not terminated by false'); - } - - return true; - }, - }) - ).not.toThrow(); - }); - - it('can get parents while walking', () => { - const treeData = [ - { key: 1, children: [{ key: 12, children: [{ key: 3 }] }, { key: 4 }] }, - { key: 5 }, - ]; - const results = []; - walk({ - treeData, - getNodeKey: keyFromTreeIndex, - ignoreCollapsed: false, - callback: ({ parentNode }) => { - results.push(parentNode ? parentNode.key : null); - }, - }); - - expect(results).toEqual([null, 1, 12, 1, null]); - }); -}); - -describe('getTreeFromFlatData', () => { - const rootKey = -1; - const argDefaults = { - rootKey, - getKey: node => node.key, - getParentKey: node => node.parentKey, - }; - - const checkFunction = ({ flatData, expected }) => { - expect( - getTreeFromFlatData({ - ...argDefaults, - flatData, - }) - ).toEqual(expected); - }; - - it('should handle empty data', () => { - [ - { flatData: [], expected: [] }, - { flatData: null, expected: [] }, - { flatData: undefined, expected: [] }, - ].forEach(checkFunction); - }); - - it('should handle [depth == 1] data', () => { - [ - { - flatData: [ - { key: 1, parentKey: rootKey }, - { key: 2, parentKey: rootKey }, - ], - expected: [ - { key: 1, parentKey: rootKey }, - { key: 2, parentKey: rootKey }, - ], - }, - { - flatData: [ - { key: '1', parentKey: rootKey }, - { key: '2', parentKey: rootKey }, - ], - expected: [ - { key: '1', parentKey: rootKey }, - { key: '2', parentKey: rootKey }, - ], - }, - ].forEach(checkFunction); - }); - - it('should handle [depth == 2] data', () => { - [ - { - flatData: [{ key: 1, parentKey: rootKey }, { key: 2, parentKey: 1 }], - expected: [ - { - key: 1, - parentKey: rootKey, - children: [{ key: 2, parentKey: 1 }], - }, - ], - }, - { - flatData: [ - { key: '1', parentKey: rootKey }, - { key: '2', parentKey: '1' }, - ], - expected: [ - { - key: '1', - parentKey: rootKey, - children: [{ key: '2', parentKey: '1' }], - }, - ], - }, - ].forEach(checkFunction); - }); - - it('should handle [depth > 2] nested data', () => { - [ - { - flatData: [ - { key: 3, parentKey: 2 }, - { key: 1, parentKey: rootKey }, - { key: 2, parentKey: 1 }, - ], - expected: [ - { - key: 1, - parentKey: rootKey, - children: [ - { - key: 2, - parentKey: 1, - children: [{ key: 3, parentKey: 2 }], - }, - ], - }, - ], - }, - { - flatData: [ - { key: 4, parentKey: 2 }, - { key: 3, parentKey: 2 }, - { key: 7, parentKey: rootKey }, - { key: 1, parentKey: rootKey }, - { key: 2, parentKey: 1 }, - { key: 6, parentKey: 1 }, - ], - expected: [ - { key: 7, parentKey: rootKey }, - { - key: 1, - parentKey: rootKey, - children: [ - { - key: 2, - parentKey: 1, - children: [{ key: 4, parentKey: 2 }, { key: 3, parentKey: 2 }], - }, - { key: 6, parentKey: 1 }, - ], - }, - ], - }, - ].forEach(checkFunction); - }); -}); - -describe('map', () => { - const checkFunction = ({ - treeData, - getNodeKey, - callback, - ignoreCollapsed, - expected, - }) => { - expect( - map({ - treeData, - getNodeKey, - callback, - ignoreCollapsed, - }) - ).toEqual(expected); - }; - - it('should handle empty data', () => { - [ - { - treeData: [], - getNodeKey: keyFromKey, - callback: ({ node }) => node, - expected: [], - }, - { - treeData: null, - getNodeKey: keyFromKey, - callback: ({ node }) => node, - expected: [], - }, - { - treeData: undefined, - getNodeKey: keyFromKey, - callback: ({ node }) => node, - expected: [], - }, - ].forEach(checkFunction); - }); - - it('can return tree as-is', () => { - [ - { - getNodeKey: keyFromKey, - callback: ({ node }) => node, - treeData: [{ key: 1 }, { key: 2 }], - expected: [{ key: 1 }, { key: 2 }], - }, - { - getNodeKey: keyFromKey, - callback: ({ node }) => node, - treeData: [{ key: 1, children: [{ key: 2 }] }], - expected: [{ key: 1, children: [{ key: 2 }] }], - }, - { - getNodeKey: keyFromKey, - callback: ({ node }) => node, - treeData: [ - { - key: 1, - children: [{ key: 12, children: [{ key: 3 }] }, { key: 4 }], - }, - { key: 5 }, - ], - expected: [ - { - key: 1, - children: [{ key: 12, children: [{ key: 3 }] }, { key: 4 }], - }, - { key: 5 }, - ], - }, - ].forEach(checkFunction); - }); - - it('can truncate part of the tree', () => { - [ - { - getNodeKey: keyFromKey, - callback: ({ node }) => - node.key === 1 ? { ...node, children: [] } : node, - treeData: [ - { - key: 1, - children: [{ key: 12, children: [{ key: 3 }] }, { key: 4 }], - }, - { key: 5 }, - ], - expected: [{ key: 1, children: [] }, { key: 5 }], - }, - ].forEach(checkFunction); - }); - - it('can get parents', () => { - checkFunction({ - getNodeKey: keyFromKey, - callback: ({ node, parentNode }) => ({ - ...node, - parentKey: parentNode ? parentNode.key : null, - }), - ignoreCollapsed: false, - treeData: [ - { - key: 1, - children: [ - { - key: 12, - children: [{ key: 3 }], - }, - { key: 4 }, - ], - }, - { key: 5 }, - ], - expected: [ - { - key: 1, - parentKey: null, - children: [ - { - key: 12, - parentKey: 1, - children: [ - { - key: 3, - parentKey: 12, - }, - ], - }, - { - key: 4, - parentKey: 1, - }, - ], - }, - { - key: 5, - parentKey: null, - }, - ], - }); - }); - - it('can sort part of the tree', () => { - [ - { - getNodeKey: keyFromKey, - callback: ({ node }) => - !node.children - ? node - : { - ...node, - children: node.children.sort((a, b) => a.key - b.key), - }, - treeData: [ - { - key: 1, - expanded: true, - children: [ - { - key: 12, - expanded: true, - children: [{ key: 33 }, { key: 3 }], - }, - { key: 4 }, - ], - }, - { key: 5 }, - ], - expected: [ - { - key: 1, - expanded: true, - children: [ - { key: 4 }, - { - key: 12, - expanded: true, - children: [{ key: 3 }, { key: 33 }], - }, - ], - }, - { key: 5 }, - ], - }, - ].forEach(checkFunction); - }); - - it('can modify every node in the tree', () => { - [ - { - getNodeKey: keyFromKey, - callback: ({ node }) => ({ ...node, expanded: true }), - ignoreCollapsed: false, - treeData: [ - { - key: 1, - children: [ - { - key: 12, - children: [{ key: 33 }, { key: 3 }], - }, - { key: 4 }, - ], - }, - { key: 5 }, - ], - expected: [ - { - key: 1, - expanded: true, - children: [ - { - key: 12, - expanded: true, - children: [ - { key: 33, expanded: true }, - { key: 3, expanded: true }, - ], - }, - { key: 4, expanded: true }, - ], - }, - { key: 5, expanded: true }, - ], - }, - ].forEach(checkFunction); - }); -}); - -describe('isDescendant', () => { - const treeData = [ - { - key: 1, - children: [ - { - key: 12, - children: [{ key: 33 }, { key: 3 }], - }, - { key: 4 }, - ], - }, - { key: 5 }, - ]; - - it('should work at the base', () => { - expect(isDescendant(treeData[0], treeData[0])).toEqual(false); - expect(isDescendant(treeData[0], treeData[1])).toEqual(false); - expect(isDescendant(treeData[0], treeData[0].children[1])).toEqual(true); - }); - - it('should work deeper in the tree', () => { - expect( - isDescendant(treeData[0].children[0], treeData[0].children[0].children[1]) - ).toEqual(true); - }); -}); - -describe('getDepth', () => { - const treeData = [ - { - key: 1, - children: [ - { - key: 12, - children: [{ key: 33 }, { key: 3 }], - }, - { key: 4 }, - ], - }, - { key: 5 }, - ]; - - it('should work at the base', () => { - expect(getDepth(treeData[0])).toEqual(2); - expect(getDepth(treeData[1])).toEqual(0); - }); - - it('should work deeper in the tree', () => { - expect(getDepth(treeData[0].children[0])).toEqual(1); - }); -}); - -describe('getDescendantCount', () => { - it('should count flat data', () => { - expect(getDescendantCount({ ignoreCollapsed: false, node: {} })).toEqual(0); - expect( - getDescendantCount({ ignoreCollapsed: false, node: { children: [] } }) - ).toEqual(0); - expect( - getDescendantCount({ ignoreCollapsed: false, node: { children: [{}] } }) - ).toEqual(1); - expect( - getDescendantCount({ - ignoreCollapsed: false, - node: { children: [{}, {}] }, - }) - ).toEqual(2); - }); - - it('should count nested data', () => { - const nested = { - expanded: true, - children: [{}, { children: [{}] }, {}], - }; - - expect( - getDescendantCount({ ignoreCollapsed: false, node: nested }) - ).toEqual(4); - expect(getDescendantCount({ ignoreCollapsed: true, node: nested })).toEqual( - 3 - ); - }); -}); - -describe('find', () => { - const commonArgs = { - searchQuery: 42, - searchMethod: ({ node, searchQuery }) => node.key === searchQuery, - expandAllMatchPaths: false, - expandFocusMatchPaths: true, - getNodeKey: keyFromKey, - searchFocusOffset: 0, - }; - - it('should work with flat data', () => { - let result; - - result = find({ ...commonArgs, treeData: [{}] }); - expect(result.matches).toEqual([]); - - result = find({ ...commonArgs, treeData: [{ key: 41 }] }); - expect(result.matches).toEqual([]); - - result = find({ ...commonArgs, treeData: [{ key: 42 }] }); - expect(result.matches).toEqual([ - { node: { key: 42 }, treeIndex: 0, path: [42] }, - ]); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(0); - - result = find({ ...commonArgs, treeData: [{ key: 41 }, { key: 42 }] }); - expect(result.matches).toEqual([ - { node: { key: 42 }, treeIndex: 1, path: [42] }, - ]); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(1); - - result = find({ ...commonArgs, treeData: [{ key: 42 }, { key: 42 }] }); - expect(result.matches).toEqual([ - { node: { key: 42 }, treeIndex: 0, path: [42] }, - { node: { key: 42 }, treeIndex: 1, path: [42] }, - ]); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(0); - - result = find({ - ...commonArgs, - searchFocusOffset: 3, - treeData: [ - { key: 1 }, - { key: 42 }, - { key: 3 }, - { key: 3 }, - { key: 3 }, - { key: 4 }, - { key: 42 }, - { key: 42 }, - { key: 4 }, - { key: 42 }, - ], - }); - expect(result.matches).toEqual([ - { node: { key: 42 }, treeIndex: 1, path: [42] }, - { node: { key: 42 }, treeIndex: 6, path: [42] }, - { node: { key: 42 }, treeIndex: 7, path: [42] }, - { node: { key: 42 }, treeIndex: 9, path: [42] }, - ]); - expect(result.matches[3].treeIndex).toEqual(9); - }); - - it('should work with nested data', () => { - let result; - - result = find({ - ...commonArgs, - treeData: [{ children: [{ key: 42 }] }], - }); - expect(result.matches.length).toEqual(1); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(1); - - result = find({ - ...commonArgs, - treeData: [{ children: [{ key: 41 }] }, { children: [{ key: 42 }] }], - }); - expect(result.matches.length).toEqual(1); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(2); - expect(result.treeData).toEqual([ - { children: [{ key: 41 }] }, - { expanded: true, children: [{ key: 42 }] }, - ]); - - result = find({ - ...commonArgs, - treeData: [{ children: [{ children: [{ key: 42 }] }] }], - }); - expect(result.matches.length).toEqual(1); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(2); - - result = find({ - ...commonArgs, - treeData: [{ children: [{ key: 42, children: [{ key: 42 }] }] }], - }); - expect(result.matches.length).toEqual(2); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(1); - - result = find({ - ...commonArgs, - treeData: [ - {}, - { children: [{ key: 42, expanded: true, children: [{ key: 42 }] }] }, - ], - }); - expect(result.matches.length).toEqual(2); - expect(result.matches[commonArgs.searchFocusOffset].treeIndex).toEqual(2); - - result = find({ - ...commonArgs, - treeData: [ - {}, - { children: [{ key: 1, expanded: true, children: [{ key: 1 }] }] }, - ], - }); - expect(result.matches.length).toEqual(0); - }); -}); - -describe('toggleExpandedForAll', () => { - it('should expand all', () => { - expect( - toggleExpandedForAll({ - treeData: [{ children: [{ children: [{}] }] }], - }) - ).toEqual([ - { - expanded: true, - children: [{ expanded: true, children: [{ expanded: true }] }], - }, - ]); - }); - it('should collapse all', () => { - expect( - toggleExpandedForAll({ - expanded: false, - treeData: [ - { - expanded: true, - children: [{ expanded: true, children: [{ expanded: true }] }], - }, - ], - }) - ).toEqual([ - { - expanded: false, - children: [{ expanded: false, children: [{ expanded: false }] }], - }, - ]); - }); -}); diff --git a/src/utils/tree-data-utils.ts b/src/utils/tree-data-utils.ts new file mode 100644 index 00000000..828663c6 --- /dev/null +++ b/src/utils/tree-data-utils.ts @@ -0,0 +1,1162 @@ +import { TreeData, TreeNode, Path } from '../types'; + +type LowerSiblingCounts = number[]; + +type FoundResult = { + node: TreeNode; + lowerSiblingCounts: LowerSiblingCounts; + path: Path; +}; +type CallbackNodeInfo = { + node: TreeNode; + parentNode: TreeNode | null; + path: Path; + lowerSiblingCounts: LowerSiblingCounts; + treeIndex: number; +}; +type MissedResult = { nextIndex: number }; + +const getNodePath = ( + node: TreeNode, + parentPath: Path, + isPseudoRoot = false +): Path => + // The pseudo-root is not considered in the path + isPseudoRoot ? [] : [...parentPath, node.nodeId]; + +/** + * Performs a depth-first traversal over all of the node descendants, + * incrementing currentIndex by 1 for each + */ +function getNodeDataAtTreeIndexOrNextIndex( + targetIndex: number, + node: TreeNode, + currentIndex: number, + path: Path = [], + lowerSiblingCounts: LowerSiblingCounts = [], + ignoreCollapsed = true, + isPseudoRoot = false +): FoundResult | MissedResult { + const selfPath = getNodePath(node, path, isPseudoRoot); + + // Return target node when found + if (currentIndex === targetIndex) { + return { + node, + lowerSiblingCounts, + path: selfPath, + }; + } + + // Add one and continue for nodes with no children or hidden children + if ( + !node.children || + typeof node.children === 'function' || + (ignoreCollapsed && node.expanded !== true) + ) { + return { nextIndex: currentIndex + 1 }; + } + + // Iterate over each child and their descendants and return the + // target node if childIndex reaches the targetIndex + let childIndex = currentIndex + 1; + const childCount = node.children.length; + for (let i = 0; i < childCount; i += 1) { + const result = getNodeDataAtTreeIndexOrNextIndex( + targetIndex, + node.children[i], + childIndex, + selfPath, + [...lowerSiblingCounts, childCount - i - 1], + ignoreCollapsed + ); + + if ('node' in result) { + return result; + } + + childIndex = result.nextIndex; + } + + // If the target node is not found, return the farthest traversed index + return { nextIndex: childIndex }; +} + +export function getDescendantCount(node: TreeNode, ignoreCollapsed = true) { + return ( + (getNodeDataAtTreeIndexOrNextIndex( + -1, // Force a miss so we count all nodes below this one + node, + 0, + [], + [], + ignoreCollapsed + ) as MissedResult).nextIndex - 1 + ); +} + +type WalkCallback = (arg: CallbackNodeInfo) => false | void; +/** + * Walk all descendants of the given node, depth-first + * + * @param callback - Function to call on each node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * @param node - A tree node + * @param parentNode - The parent node of `node` + * @param currentIndex - The treeIndex of `node` + * @param path - Array of nodeIds leading up to node to be changed + * @param lowerSiblingCounts - An array containing the count of siblings beneath the + * previous nodes in this path + * @param isPseudoRoot - If true, this node has no real data, and only serves + * as the parent of all the nodes in the tree + * + * @return nextIndex - Index of the next sibling of `node`, + * or false if the walk should be terminated + */ +function walkDescendants( + callback: WalkCallback, + ignoreCollapsed: boolean, + node: TreeNode, + currentIndex: number, + parentNode: TreeNode | null = null, + path: Path = [], + lowerSiblingCounts: LowerSiblingCounts = [], + isPseudoRoot = false +): number | false { + // The pseudo-root is not considered in the path + const selfPath = getNodePath(node, path, isPseudoRoot); + const selfInfo = isPseudoRoot + ? null + : { + node, + parentNode, + path: selfPath, + lowerSiblingCounts, + treeIndex: currentIndex, + }; + + if (selfInfo !== null) { + const callbackResult = callback(selfInfo); + + // Cut walk short if the callback returned false + if (callbackResult === false) { + return false; + } + } + + // Return self on nodes with no children or hidden children + if ( + !node.children || + (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) + ) { + return currentIndex; + } + + // Get all descendants + let childIndex: number | false = currentIndex; + const childCount = node.children.length; + if (typeof node.children !== 'function') { + for (let i = 0; i < childCount; i += 1) { + childIndex = walkDescendants( + callback, + ignoreCollapsed, + node.children[i], + childIndex + 1, + isPseudoRoot ? null : node, + selfPath, + [...lowerSiblingCounts, childCount - i - 1] + ); + + // Cut walk short if the callback returned false + if (childIndex === false) { + return false; + } + } + } + + return childIndex; +} + +type MapCallbackFn = (arg: CallbackNodeInfo) => TreeNode; +/** + * Perform a change on the given node and all its descendants, traversing the tree depth-first + * + * @param callback - Function to call on each node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * @param node - A tree node + * @param parentNode - The parent node of `node` + * @param currentIndex - The treeIndex of `node` + * @param path - Array of nodeIds leading up to node to be changed + * @param lowerSiblingCounts - An array containing the count of siblings beneath the + * previous nodes in this path + * @param isPseudoRoot - If true, this node has no real data, and only serves + * as the parent of all the nodes in the tree + */ +function mapDescendants( + callback: MapCallbackFn, + ignoreCollapsed: boolean, + node: TreeNode, + parentNode: TreeNode | null, + currentIndex: number, + path: Path, + lowerSiblingCounts: LowerSiblingCounts, + isPseudoRoot = false +) { + const nextNode = { ...node }; + + const selfPath = getNodePath(nextNode, path, isPseudoRoot); + const selfInfo = { + node: nextNode, + parentNode, + path: selfPath, + lowerSiblingCounts, + treeIndex: currentIndex, + }; + + // Return self on nodes with no children or hidden children + if ( + !nextNode.children || + (nextNode.expanded !== true && ignoreCollapsed && !isPseudoRoot) + ) { + return { + treeIndex: currentIndex, + node: callback(selfInfo), + }; + } + + // Get all descendants + let childIndex = currentIndex; + const childCount = nextNode.children.length; + if (typeof nextNode.children !== 'function') { + nextNode.children = nextNode.children.map((child, i) => { + const mapResult = mapDescendants( + callback, + ignoreCollapsed, + child, + isPseudoRoot ? null : nextNode, + childIndex + 1, + selfPath, + [...lowerSiblingCounts, childCount - i - 1] + ); + childIndex = mapResult.treeIndex; + + return mapResult.node; + }); + } + + return { + node: callback(selfInfo), + treeIndex: childIndex, + }; +} + +/** + * Count all the visible (expanded) descendants in the tree data. + * + * @param treeData - Tree data + * + * @return count + */ +export function getVisibleNodeCount(treeData: TreeData) { + const traverse = (node: TreeNode): number => { + if ( + !node.children || + node.expanded !== true || + typeof node.children === 'function' + ) { + return 1; + } + + return ( + 1 + + node.children.reduce( + (total, currentNode) => total + traverse(currentNode), + 0 + ) + ); + }; + + return treeData.reduce( + (total: number, currentNode: TreeNode) => total + traverse(currentNode), + 0 + ); +} + +/** + * Get the th visible node in the tree data. + * + * @param treeData - Tree data + * @param targetIndex - The index of the node to search for + * + * @return node - The node at targetIndex, or null if not found + */ +export function getVisibleNodeInfoAtIndex( + treeData: TreeData, + targetIndex: number +) { + if (treeData.length < 1) { + return null; + } + + // Call the tree traversal with a pseudo-root node + const result = getNodeDataAtTreeIndexOrNextIndex( + targetIndex, + { + nodeId: '__rst_pseudo_root', + children: treeData, + expanded: true, + }, + -1, + [], + [], + true, + true + ); + + if ('node' in result) { + return result; + } + + return null; +} + +/** + * Walk descendants depth-first and call a callback on each + * + * @param treeData - Tree data + * @param callback - Function to call on each node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return void + */ +export function walk( + treeData: TreeData, + callback: WalkCallback, + ignoreCollapsed = true +) { + if (treeData.length < 1) { + return; + } + + walkDescendants( + callback, + ignoreCollapsed, + { nodeId: '__rst_pseudo_root', children: treeData }, + -1, + null, + [], + [], + true + ); +} + +/** + * Perform a depth-first transversal of the descendants and + * make a change to every node in the tree + * + * @param treeData - Tree data + * @param callback - Function to call on each node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return changedTreeData - The changed tree data + */ +export function map( + treeData: TreeData, + callback: MapCallbackFn, + ignoreCollapsed = true +): TreeData { + if (!treeData || treeData.length < 1) { + return []; + } + + return mapDescendants( + callback, + ignoreCollapsed, + { nodeId: '__rst_pseudo_root', children: treeData }, + null, + -1, + [], + [], + true + ).node.children as TreeData; +} + +/** + * Expand or close every node in the tree + * + * @param {!Object[]} treeData - Tree data + * @param {?boolean} expanded - Whether the node is expanded or not + * + * @return {Object[]} changedTreeData - The changed tree data + */ +export function toggleExpandedForAll(treeData: TreeData, expanded: boolean) { + return map(treeData, ({ node }) => ({ ...node, expanded }), false); +} + +/** + * Replaces node at path with object, or callback-defined object + * + * @param treeData + * @param path - Array of nodeIds leading up to node to be changed + * @param newNode - TreeNode to replace the node at the path with, or a function producing the new node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return changedTreeData - The changed tree data + */ +export function changeNodeAtPath( + treeData: TreeData, + path: Path, + newNode: + | TreeNode + | (({ + node, + treeIndex, + }: { + node: TreeNode; + treeIndex: number; + }) => TreeNode | null), + ignoreCollapsed = true +) { + const RESULT_MISS = 'RESULT_MISS'; + const traverse = ( + node: TreeNode, + currentTreeIndex: number, + pathIndex: number, + isPseudoRoot = false + ): TreeNode | null | typeof RESULT_MISS => { + if (!isPseudoRoot && node.nodeId !== path[pathIndex]) { + return RESULT_MISS; + } + + if (pathIndex >= path.length - 1) { + // If this is the final location in the path, return its changed form + return typeof newNode === 'function' + ? newNode({ node, treeIndex: currentTreeIndex }) + : newNode; + } + if (!node.children || typeof node.children === 'function') { + // If this node is part of the path, but has no children, + // or the children have not been loaded, + // return the unchanged node + throw new Error('Path referenced children of node with no children.'); + } + + let nextTreeIndex = currentTreeIndex + 1; + for (let i = 0; i < node.children.length; i += 1) { + const result = traverse(node.children[i], nextTreeIndex, pathIndex + 1); + + // If the result went down the correct path + if (result !== RESULT_MISS) { + if (result) { + // If the result was truthy (in this case, an object), + // pass it to the next level of recursion up + return { + ...node, + children: [ + ...node.children.slice(0, i), + result, + ...node.children.slice(i + 1), + ], + }; + } + // If the result was falsy (returned from the newNode function), then + // delete the node from the array. + return { + ...node, + children: [ + ...node.children.slice(0, i), + ...node.children.slice(i + 1), + ], + }; + } + + nextTreeIndex += + 1 + getDescendantCount(node.children[i], ignoreCollapsed); + } + + return RESULT_MISS; + }; + + // Use a pseudo-root node in the beginning traversal + const result = traverse( + { nodeId: '__rst_pseudo_root', children: treeData }, + -1, + -1, + true + ); + + if (result === RESULT_MISS) { + throw new Error('No node found at the given path.'); + } + + return (result as TreeNode).children; +} + +/** + * Removes the node at the specified path and returns the resulting treeData. + * + * @param treeData + * @param path - Array of nodeIds leading up to node to be deleted + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return result + * @return result.treeData - The tree data with the node removed + * @return result.node - The node that was removed + * @return result.treeIndex - The previous treeIndex of the removed node + */ +export function removeNodeAtPath( + treeData: TreeData, + path: Path, + ignoreCollapsed = true +) { + let removedNode = null; + let removedTreeIndex = null; + const nextTreeData = changeNodeAtPath( + treeData, + path, + ({ node, treeIndex }: { node: TreeNode; treeIndex: number }) => { + // Store the target node and delete it from the tree + removedNode = node; + removedTreeIndex = treeIndex; + + return null; + }, + ignoreCollapsed + ); + + return { + treeData: nextTreeData, + node: removedNode, + treeIndex: removedTreeIndex, + }; +} + +/** + * Gets the node at the specified path + * + * @param treeData + * @param path - Array of nodeIds leading up to node to be deleted + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return nodeInfo - The node info at the given path, or null if not found + */ +export function getNodeAtPath( + treeData: TreeData, + path: Path, + ignoreCollapsed = true +) { + let foundNodeInfo = null; + + try { + changeNodeAtPath( + treeData, + path, + ({ node, treeIndex }) => { + foundNodeInfo = { node, treeIndex }; + return node; + }, + ignoreCollapsed + ); + } catch (err) { + // Ignore the error -- the null return will be explanation enough + } + + return foundNodeInfo; +} + +/** + * Adds the node to the specified parent and returns the resulting treeData. + * + * @param treeData + * @param newNode - The node to insert + * @param parentNodeId - The nodeId of the to-be parentNode of the node + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * @param expandParent - If true, expands the parentNode specified by parentPath + * @param addAsFirstChild - If true, adds new node as first child of tree + * + * @return result + * @return result.treeData - The updated tree data + * @return result.treeIndex - The tree index at which the node was inserted + */ +export function addNodeUnderParent( + treeData: TreeData, + newNode: TreeNode, + parentNodeId: string | null = null, + addAsFirstChild = false, + ignoreCollapsed = true, + expandParent = false +) { + if (parentNodeId === null) { + return addAsFirstChild + ? { + treeData: [newNode, ...(treeData || [])], + treeIndex: 0, + } + : { + treeData: [...(treeData || []), newNode], + treeIndex: (treeData || []).length, + }; + } + + let insertedTreeIndex: number | null = null; + let hasBeenAdded = false; + const changedTreeData = map( + treeData, + ({ node, treeIndex, path }) => { + const nodeId = path ? path[path.length - 1] : null; + // Return nodes that are not the parent as-is + if (hasBeenAdded || nodeId !== parentNodeId) { + return node; + } + hasBeenAdded = true; + + const parentNode = { + ...node, + }; + + if (expandParent) { + parentNode.expanded = true; + } + + // If no children exist yet, just add the single newNode + if (!parentNode.children) { + insertedTreeIndex = treeIndex + 1; + return { + ...parentNode, + children: [newNode], + }; + } + + if (typeof parentNode.children === 'function') { + throw new Error('Cannot add to children defined by a function'); + } + + let nextTreeIndex = treeIndex + 1; + for (let i = 0; i < parentNode.children.length; i += 1) { + nextTreeIndex += + 1 + getDescendantCount(parentNode.children[i], ignoreCollapsed); + } + + insertedTreeIndex = nextTreeIndex; + + const children = addAsFirstChild + ? [newNode, ...parentNode.children] + : [...parentNode.children, newNode]; + + return { + ...parentNode, + children, + }; + }, + ignoreCollapsed + ); + + if (!hasBeenAdded) { + throw new Error('No node found with the given nodeId.'); + } + + return { + treeData: changedTreeData, + treeIndex: insertedTreeIndex, + }; +} + +type FailedInsertResult = { + node: TreeNode; + nextIndex: number; +}; +type SuccessfulInsertResult = FailedInsertResult & { + insertedTreeIndex: number; + parentPath: Path; + parentNode: TreeNode | null; +}; +type InsertResult = FailedInsertResult | SuccessfulInsertResult; +function addNodeAtDepthAndIndex( + targetDepth: number, + newNode: TreeNode, + minimumTreeIndex: number, + ignoreCollapsed: boolean, + expandParent: boolean, + isLastChild: boolean, + node: TreeNode, + currentIndex: number, + currentDepth: number, + path: Path = [], + isPseudoRoot = false +): InsertResult { + // If the current position is the only possible place to add, add it + if ( + currentIndex >= minimumTreeIndex - 1 || + (isLastChild && !(node.children && node.children.length)) + ) { + if (typeof node.children === 'function') { + throw new Error('Cannot add to children defined by a function'); + } else { + const extraNodeProps = expandParent ? { expanded: true } : {}; + const nextNode = { + ...node, + + ...extraNodeProps, + children: node.children ? [newNode, ...node.children] : [newNode], + }; + + return { + node: nextNode, + nextIndex: currentIndex + 2, + insertedTreeIndex: currentIndex + 1, + parentPath: getNodePath(nextNode, path, isPseudoRoot), + parentNode: isPseudoRoot ? null : nextNode, + }; + } + } + + // If this is the target depth for the insertion, + // i.e., where the newNode can be added to the current node's children + if (currentDepth >= targetDepth - 1) { + // Skip over nodes with no children or hidden children + if ( + !node.children || + typeof node.children === 'function' || + (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) + ) { + return { node, nextIndex: currentIndex + 1 }; + } + + // Scan over the children to see if there's a place among them that fulfills + // the minimumTreeIndex requirement + let childIndex = currentIndex + 1; + let insertedTreeIndex: number | undefined = undefined; + let insertIndex: number | undefined = undefined; + for (let i = 0; i < node.children.length; i += 1) { + // If a valid location is found, mark it as the insertion location and + // break out of the loop + if (childIndex >= minimumTreeIndex) { + insertedTreeIndex = childIndex; + insertIndex = i; + break; + } + + // Increment the index by the child itself plus the number of descendants it has + childIndex += 1 + getDescendantCount(node.children[i], ignoreCollapsed); + } + + // If no valid indices to add the node were found + if (insertIndex === undefined) { + // If the last position in this node's children is less than the minimum index + // and there are more children on the level of this node, return without insertion + if (childIndex < minimumTreeIndex && !isLastChild) { + return { node, nextIndex: childIndex }; + } + + // Use the last position in the children array to insert the newNode + insertedTreeIndex = childIndex; + insertIndex = node.children.length; + } + + // Insert the newNode at the insertIndex + const nextNode = { + ...node, + children: [ + ...node.children.slice(0, insertIndex), + newNode, + ...node.children.slice(insertIndex), + ], + }; + + // Return node with successful insert result + return { + node: nextNode, + nextIndex: childIndex, + insertedTreeIndex, + parentPath: getNodePath(nextNode, path, isPseudoRoot), + parentNode: isPseudoRoot ? null : nextNode, + }; + } + + // Skip over nodes with no children or hidden children + if ( + !node.children || + typeof node.children === 'function' || + (node.expanded !== true && ignoreCollapsed && !isPseudoRoot) + ) { + return { node, nextIndex: currentIndex + 1 }; + } + + // Get all descendants + let insertedTreeIndex: number | null = null; + let pathFragment: Path = []; + let parentNode = null; + let childIndex = currentIndex + 1; + let newChildren = node.children; + if (typeof newChildren !== 'function') { + newChildren = newChildren.map((child, i) => { + if (insertedTreeIndex !== null) { + return child; + } + + const mapResult = addNodeAtDepthAndIndex( + targetDepth, + newNode, + minimumTreeIndex, + ignoreCollapsed, + expandParent, + isLastChild && i === newChildren.length - 1, + child, + childIndex, + currentDepth + 1, + [] // Cannot determine the parent path until the children have been processed + ); + + if ('insertedTreeIndex' in mapResult) { + ({ + insertedTreeIndex, + parentNode, + parentPath: pathFragment, + } = mapResult); + } + + childIndex = mapResult.nextIndex; + + return mapResult.node; + }); + } + + const nextNode = { ...node, children: newChildren }; + const result: InsertResult = { + node: nextNode, + nextIndex: childIndex, + ...(insertedTreeIndex === null + ? {} + : { + insertedTreeIndex: insertedTreeIndex, + parentPath: [ + ...getNodePath(nextNode, path, isPseudoRoot), + ...pathFragment, + ], + parentNode, + }), + }; + + return result; +} + +/** + * Insert a node into the tree at the given depth, after the minimum index + * + * @param treeData - Tree data + * @param depth - The depth to insert the node at (the first level of the array being depth 0) + * @param minimumTreeIndex - The lowest possible treeIndex to insert the node at + * @param newNode - The node to insert into the tree + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * @param expandParent - If true, expands the parent of the inserted node + * + * @return result + * @return result.treeData - The tree data with the node added + * @return result.treeIndex - The tree index at which the node was inserted + * @return result.path - Array of nodeIds leading to the node location after insertion + * @return result.parentNode - The parent node of the inserted node + */ +export function insertNode( + treeData: TreeData, + newNode: TreeNode, + targetDepth: number, + minimumTreeIndex: number, + ignoreCollapsed = true, + expandParent = false +) { + if (!treeData && targetDepth === 0) { + return { + treeData: [newNode], + treeIndex: 0, + path: [newNode.nodeId], + parentNode: null, + }; + } + + const insertResult = addNodeAtDepthAndIndex( + targetDepth, + newNode, + minimumTreeIndex, + ignoreCollapsed, + expandParent, + true, + { nodeId: '__rst_pseudo_root', children: treeData }, + -1, + -1, + [], + true + ); + + if (!('insertedTreeIndex' in insertResult)) { + throw new Error('No suitable position found to insert.'); + } + + const treeIndex = insertResult.insertedTreeIndex; + return { + treeData: insertResult.node.children, + treeIndex, + path: [...insertResult.parentPath, newNode.nodeId], + parentNode: insertResult.parentNode, + }; +} + +/** + * Get tree data flattened. + * + * @param treeData - Tree data + * @param ignoreCollapsed - Ignore children of nodes without `expanded` set to `true` + * + * @return nodes - The node array + */ +export function getFlatDataFromTree( + treeData: TreeData, + ignoreCollapsed = true +) { + if (!treeData || treeData.length < 1) { + return []; + } + + const flattened: CallbackNodeInfo[] = []; + walk( + treeData, + nodeInfo => { + flattened.push(nodeInfo); + }, + ignoreCollapsed + ); + + return flattened; +} + +type GetNodeIdFn = (obj: any) => string; +/** + * Generate a tree structure from flat data. + * + * @param flatData + * @param getNodeId - Function to get the nodeId from the nodeData + * @param getParentNodeId - Function to get the parent nodeId from the nodeData + * @param rootNodeId - The value returned by `getParentNodeId` that corresponds to the root node. + * For example, if your nodes have id 1-99, you might use rootNodeId = 0 + * + * @return treeData - The flat data represented as a tree + */ +export function getTreeFromFlatData( + flatData: Object[], + getNodeId: GetNodeIdFn = node => node.id, + getParentNodeId: GetNodeIdFn = node => node.parentId, + rootNodeId = '0' +): TreeData { + const childrenToParents: { [nodeId: string]: Object[] } = {}; + flatData.forEach(child => { + const parentNodeId = getParentNodeId(child); + + if (parentNodeId in childrenToParents) { + childrenToParents[parentNodeId].push(child); + } else { + childrenToParents[parentNodeId] = [child]; + } + }); + + if (!(rootNodeId in childrenToParents)) { + return []; + } + + const trav = (parent: {}): TreeNode => { + const parentNodeId = getNodeId(parent); + const nextNode: Omit = + parentNodeId in childrenToParents + ? { + ...parent, + children: childrenToParents[parentNodeId].map(child => trav(child)), + } + : { ...parent }; + return { ...nextNode, nodeId: getNodeId(nextNode) }; + }; + + return childrenToParents[rootNodeId].map(child => trav(child)); +} + +/** + * Check if a node is a descendant of another node. + * + * @param older - Potential ancestor of younger node + * @param younger - Potential descendant of older node + * + * @return isDescendant + */ +export function isDescendant(older: TreeNode, younger: TreeNode): boolean { + return ( + !!older.children && + typeof older.children !== 'function' && + older.children.some( + child => child === younger || isDescendant(child, younger) + ) + ); +} + +/** + * Get the maximum depth of the children (the depth of the root node is 0). + * + * @param node - Node in the tree + * @param depth - The current depth + * + * @return maxDepth - The deepest depth in the tree + */ +export function getDepth(node: TreeNode, depth = 0): number { + if (!node.children) { + return depth; + } + + if (typeof node.children === 'function') { + return depth + 1; + } + + return node.children.reduce( + (deepest, child) => Math.max(deepest, getDepth(child, depth + 1)), + depth + ); +} + +type Match = { path?: Path; treeIndex?: number | null; node: TreeNode }; +type SearchMethod = (args: Match & { searchQuery: any }) => boolean; + +/** + * Find nodes matching a search query in the tree, + * + * @param treeData - Tree data + * @param searchQuery - Function returning a boolean to indicate whether the node is a match or not + * @param searchMethod - Function returning a boolean to indicate whether the node is a match or not + * @param searchFocusOffset - The offset of the match to focus on + * (e.g., 0 focuses on the first match, 1 on the second) + * @param expandAllMatchPaths - If true, expands the paths to any matched node + * @param expandFocusMatchPaths - If true, expands the path to the focused node + * + * @return result + * @return result.matches - An array of objects containing the matching `node`s, their `path`s and `treeIndex`s + * @return result.treeData - The original tree data with all relevant nodes expanded. + * If expandAllMatchPaths and expandFocusMatchPaths are both false, + * it will be the same as the original tree data. + */ +export function find( + treeData: TreeData, + searchQuery: any, + searchMethod: SearchMethod, + searchFocusOffset?: number, + expandAllMatchPaths = false, + expandFocusMatchPaths = true +) { + let matchCount = 0; + const trav = ( + node: TreeNode, + currentIndex: number, + path: Path, + isPseudoRoot = false + ) => { + let matches: Match[] = []; + let isSelfMatch = false; + let hasFocusMatch = false; + + const selfPath = getNodePath(node, path, isPseudoRoot); + const extraInfo = isPseudoRoot + ? null + : { + path: selfPath, + treeIndex: currentIndex, + }; + + // Examine the current node to see if it is a match + if (!isPseudoRoot && searchMethod({ ...extraInfo, node, searchQuery })) { + if (matchCount === searchFocusOffset) { + hasFocusMatch = true; + } + + // Keep track of the number of matching nodes, so we know when the searchFocusOffset + // is reached + matchCount += 1; + + // We cannot add this node to the matches right away, as it may be changed + // during the search of the descendants. The entire node is used in + // comparisons between nodes inside the `matches` and `treeData` results + // of this method (`find`) + isSelfMatch = true; + } + + let childIndex = currentIndex; + const newNode = { ...node }; + + // Nodes with with children that aren't lazy + if ( + newNode.children && + typeof newNode.children !== 'function' && + newNode.children.length > 0 + ) { + // Get all descendants + newNode.children = newNode.children.map(child => { + const mapResult = trav(child, childIndex + 1, selfPath); + + // Ignore hidden nodes by only advancing the index counter to the returned treeIndex + // if the child is expanded. + // + // The child could have been expanded from the start, + // or expanded due to a matching node being found in its descendants + if (mapResult.node.expanded) { + childIndex = mapResult.treeIndex; + } else { + childIndex += 1; + } + + if (mapResult.matches.length > 0 || mapResult.hasFocusMatch) { + matches = [...matches, ...mapResult.matches]; + if (mapResult.hasFocusMatch) { + hasFocusMatch = true; + } + + // Expand the current node if it has descendants matching the search + // and the settings are set to do so. + if ( + (expandAllMatchPaths && mapResult.matches.length > 0) || + ((expandAllMatchPaths || expandFocusMatchPaths) && + mapResult.hasFocusMatch) + ) { + newNode.expanded = true; + } + } + + return mapResult.node; + }); + } + + // Cannot assign a treeIndex to hidden nodes + if (!isPseudoRoot && !newNode.expanded) { + matches = matches.map(match => ({ + ...match, + treeIndex: null, + })); + } + + // Add this node to the matches if it fits the search criteria. + // This is performed at the last minute so newNode can be sent in its final form. + if (isSelfMatch) { + matches = [{ ...extraInfo, node: newNode }, ...matches]; + } + + return { + node: matches.length > 0 ? newNode : node, + matches, + hasFocusMatch, + treeIndex: childIndex, + }; + }; + + const result = trav( + { nodeId: '__rst_pseudo_root', children: treeData }, + -1, + [], + true + ); + + return { + matches: result.matches, + treeData: result.node.children, + }; +} diff --git a/start.sh b/start.sh new file mode 100755 index 00000000..4f85e80b --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +"$(npm bin)/tsc" --noEmit --watch & +"$(npm bin)/parcel" -d build ./website/index.html diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..e41b9f59 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "jsx": "react", + "module": "es6", + "moduleResolution": "node", + "outDir": "./dist/", + "sourceMap": true, + "strict": true, + "target": "es5" + }, + "include": ["./src/"], + "exclude": ["./dist/", "node_modules", "**/*.spec.ts", "**/*.spec.tsx"] +} diff --git a/website/index.js b/website/index.js index f9c07807..9c0c6dc8 100644 --- a/website/index.js +++ b/website/index.js @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'; import './index.css'; import '../style.css'; +import './sandbox/styles.css'; +import SandboxApp from './sandbox/App'; class App extends React.Component { render() { @@ -33,11 +35,14 @@ class App extends React.Component {
Drag-and-drop sortable component for nested data and hierarchies
+ + {/*