UNPKG

@marciocamello/react-sortable-tree

Version:

Drag-and-drop sortable component for nested data and hierarchies

1,072 lines (954 loc) 26.3 kB
// @ts-nocheck import { FullTree, GetNodeKeyFunction, GetTreeItemChildren, NodeData, SearchData, TreeIndex, TreeItem, TreeNode, TreePath, } from '..' export type WalkAndMapFunctionParameters = FullTree & { getNodeKey: GetNodeKeyFunction callback: Function ignoreCollapsed?: boolean | undefined } export interface FlatDataItem extends TreeNode, TreePath { lowerSiblingCounts: number[] parentNode: TreeItem } /** * Performs a depth-first traversal over all of the node descendants, * incrementing currentIndex by 1 for each */ const getNodeDataAtTreeIndexOrNextIndex = ({ targetIndex, node, currentIndex, getNodeKey, path = [], lowerSiblingCounts = [], ignoreCollapsed = true, isPseudoRoot = false, }: { targetIndex: number node: TreeItem currentIndex: number getNodeKey: GetNodeKeyFunction path: number[] lowerSiblingCounts: number[] ignoreCollapsed: boolean isPseudoRoot: boolean }) => { // 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 const getDescendantCount = ({ node, ignoreCollapsed = true, }: TreeNode & { ignoreCollapsed?: boolean | undefined }): number => { return ( getNodeDataAtTreeIndexOrNextIndex({ getNodeKey: () => {}, ignoreCollapsed, node, currentIndex: 0, targetIndex: -1, }).nextIndex - 1 ) } const walkDescendants = ({ callback, getNodeKey, ignoreCollapsed, isPseudoRoot = false, node, parentNode = undefined, currentIndex, path = [], lowerSiblingCounts = [], }) => { // The pseudo-root is not considered in the path const selfPath = isPseudoRoot ? [] : [...path, getNodeKey({ node, treeIndex: currentIndex })] const selfInfo = isPseudoRoot ? undefined : { 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 ? undefined : 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 } const mapDescendants = ({ callback, getNodeKey, ignoreCollapsed, isPseudoRoot = false, node, parentNode = undefined, 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 ? undefined : nextNode, currentIndex: childIndex + 1, lowerSiblingCounts: [...lowerSiblingCounts, childCount - i - 1], path: selfPath, }) childIndex = mapResult.treeIndex return mapResult.node }) } return { node: callback(selfInfo), treeIndex: childIndex, } } export const getVisibleNodeCount = ({ treeData }: FullTree): number => { 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 ) } export const getVisibleNodeInfoAtIndex = ({ treeData, index: targetIndex, getNodeKey, }: FullTree & { index: number getNodeKey: GetNodeKeyFunction }): (TreeNode & TreePath & { lowerSiblingCounts: number[] }) | null => { if (!treeData || treeData.length === 0) { return undefined } // 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 undefined } export const walk = ({ treeData, getNodeKey, callback, ignoreCollapsed = true, }: WalkAndMapFunctionParameters): void => { if (!treeData || treeData.length === 0) { return } walkDescendants({ callback, getNodeKey, ignoreCollapsed, isPseudoRoot: true, node: { children: treeData }, currentIndex: -1, path: [], lowerSiblingCounts: [], }) } export const map = ({ treeData, getNodeKey, callback, ignoreCollapsed = true, }: WalkAndMapFunctionParameters): TreeItem[] => { if (!treeData || treeData.length === 0) { return [] } return mapDescendants({ callback, getNodeKey, ignoreCollapsed, isPseudoRoot: true, node: { children: treeData }, currentIndex: -1, path: [], lowerSiblingCounts: [], }).node.children } export const toggleExpandedForAll = ({ treeData, expanded = true, }: FullTree & { expanded?: boolean | undefined }): TreeItem[] => { return map({ treeData, callback: ({ node }) => ({ ...node, expanded }), getNodeKey: ({ treeIndex }) => treeIndex, ignoreCollapsed: false, }) } export const changeNodeAtPath = ({ treeData, path, newNode, getNodeKey, ignoreCollapsed = true, }: FullTree & TreePath & { newNode: Function | any getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined }): TreeItem[] => { 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 } export const removeNodeAtPath = ({ treeData, path, getNodeKey, ignoreCollapsed = true, }: FullTree & TreePath & { getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined }): TreeItem[] => { return changeNodeAtPath({ treeData, path, getNodeKey, ignoreCollapsed, newNode: undefined, // Delete the node }) } export const removeNode = ({ treeData, path, getNodeKey, ignoreCollapsed = true, }: FullTree & TreePath & { getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined }): (FullTree & TreeNode & TreeIndex) | undefined => { let removedNode let removedTreeIndex 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 undefined }, }) return { treeData: nextTreeData, node: removedNode, treeIndex: removedTreeIndex, } } export const getNodeAtPath = ({ treeData, path, getNodeKey, ignoreCollapsed = true, }: FullTree & TreePath & { getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined }): (TreeNode & TreeIndex) | null => { let foundNodeInfo try { changeNodeAtPath({ treeData, path, getNodeKey, ignoreCollapsed, newNode: ({ node, treeIndex }: GetTreeItemChildren) => { foundNodeInfo = { node, treeIndex } return node }, }) } catch { // Ignore the error -- the null return will be explanation enough } return foundNodeInfo } export const addNodeUnderParent = ({ treeData, newNode, parentKey = undefined, getNodeKey, ignoreCollapsed = true, expandParent = false, addAsFirstChild = false, }: FullTree & { newNode: TreeItem parentKey?: number | string | undefined | null getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined expandParent?: boolean | undefined addAsFirstChild?: boolean | undefined }): FullTree & TreeIndex => { if (parentKey === null || parentKey === undefined) { return addAsFirstChild ? { treeData: [newNode, ...(treeData || [])], treeIndex: 0, } : { treeData: [...(treeData || []), newNode], treeIndex: (treeData || []).length, } } let insertedTreeIndex let hasBeenAdded = false const changedTreeData = map({ treeData, getNodeKey, ignoreCollapsed, callback: ({ node, treeIndex, path }: GetTreeItemChildren) => { const key = path ? path[path.length - 1] : undefined // 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 TypeError('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, } } const 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 > 0)) ) { if (typeof node.children === 'function') { throw new TypeError('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 ? undefined : 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 let insertIndex 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 || 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: selfPath(nextNode), parentNode: isPseudoRoot ? undefined : 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 let pathFragment let parentNode let childIndex = currentIndex + 1 let newChildren = node.children if (typeof newChildren !== 'function') { newChildren = newChildren.map((child, i) => { if (insertedTreeIndex !== null && insertedTreeIndex !== undefined) { 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 && insertedTreeIndex !== undefined) { result.insertedTreeIndex = insertedTreeIndex result.parentPath = [...selfPath(nextNode), ...pathFragment] result.parentNode = parentNode } return result } export const insertNode = ({ treeData, depth: targetDepth, minimumTreeIndex, newNode, getNodeKey, ignoreCollapsed = true, expandParent = false, }: FullTree & { depth: number newNode: TreeItem minimumTreeIndex: number ignoreCollapsed?: boolean | undefined expandParent?: boolean | undefined getNodeKey: GetNodeKeyFunction }): FullTree & TreeIndex & TreePath & { parentNode: TreeItem | null } => { if (!treeData && targetDepth === 0) { return { treeData: [newNode], treeIndex: 0, path: [getNodeKey({ node: newNode, treeIndex: 0 })], parentNode: undefined, } } 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, } } export const getFlatDataFromTree = ({ treeData, getNodeKey, ignoreCollapsed = true, }: FullTree & { getNodeKey: GetNodeKeyFunction ignoreCollapsed?: boolean | undefined }): FlatDataItem[] => { if (!treeData || treeData.length === 0) { return [] } const flattened = [] walk({ treeData, getNodeKey, ignoreCollapsed, callback: (nodeInfo) => { flattened.push(nodeInfo) }, }) return flattened } export const getTreeFromFlatData = ({ flatData, getKey = (node) => node.id, getParentKey = (node) => node.parentId, rootKey = '0', }) => { if (!flatData) { return [] } const childrenToParents = {} for (const child of flatData) { 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)) } export const isDescendant = (older: TreeItem, younger: TreeItem): boolean => { return ( !!older.children && typeof older.children !== 'function' && older.children.some( (child) => child === younger || isDescendant(child, younger) ) ) } export const getDepth = (node: TreeItem, 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 ) } export const find = ({ getNodeKey, treeData, searchQuery, searchMethod, searchFocusOffset, expandAllMatchPaths = false, expandFocusMatchPaths = true, }: FullTree & { getNodeKey: GetNodeKeyFunction searchQuery?: string | number | undefined searchMethod: (data: SearchData) => boolean searchFocusOffset?: number | undefined expandAllMatchPaths?: boolean | undefined expandFocusMatchPaths?: boolean | undefined }): { matches: NodeData[] } & FullTree => { let matchCount = 0 const trav = ({ isPseudoRoot = false, node, currentIndex, path = [] }) => { let matches: any[] = [] 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 ? undefined : { 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: undefined, })) } // 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, } }