diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 580279b8e4c..844f73f697a 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -107,6 +107,20 @@ export interface TreeData { */ move(key: Key, toParentKey: Key | null, index: number): void, + /** + * Moves one or more items before a given key. + * @param key - The key of the item to move the items before. + * @param keys - The keys of the items to move. + */ + moveBefore(key: Key, keys: Iterable): void, + + /** + * Moves one or more items after a given key. + * @param key - The key of the item to move the items after. + * @param keys - The keys of the items to move. + */ + moveAfter(key: Key, keys: Iterable): void, + /** * Updates an item in the tree. * @param key - The key of the item to update. @@ -115,6 +129,11 @@ export interface TreeData { update(key: Key, newValue: T): void } +interface TreeDataState { + items: TreeNode[], + nodeMap: Map> +} + /** * Manages state for an immutable tree data structure, and provides convenience methods to * update the data over time. @@ -128,7 +147,7 @@ export function useTreeData(options: TreeOptions): TreeData } = options; // We only want to compute this on initial render. - let [tree, setItems] = useState<{items: TreeNode[], nodeMap: Map>}>(() => buildTree(initialItems, new Map())); + let [tree, setItems] = useState>(() => buildTree(initialItems, new Map())); let {items, nodeMap} = tree; let [selectedKeys, setSelectedKeys] = useState(new Set(initialSelectedKeys || [])); @@ -141,7 +160,7 @@ export function useTreeData(options: TreeOptions): TreeData items: initialItems.map(item => { let node: TreeNode = { key: getKey(item), - parentKey: parentKey, + parentKey: parentKey ?? null, value: item, children: null }; @@ -154,9 +173,9 @@ export function useTreeData(options: TreeOptions): TreeData }; } - function updateTree(items: TreeNode[], key: Key, update: (node: TreeNode) => TreeNode | null, originalMap: Map>) { - let node = originalMap.get(key); - if (!node) { + function updateTree(items: TreeNode[], key: Key | null, update: (node: TreeNode) => TreeNode | null, originalMap: Map>) { + let node = key == null ? null : originalMap.get(key); + if (node == null) { return {items, nodeMap: originalMap}; } let map = new Map>(originalMap); @@ -352,6 +371,8 @@ export function useTreeData(options: TreeOptions): TreeData // If parentKey is null, insert into the root. if (toParentKey == null) { + // safe to reuse the original map since no node was actually removed, so we just need to update the one moved node + newMap = new Map(originalMap); newMap.set(movedNode.key, movedNode); return {items: [ ...newItems.slice(0, index), @@ -373,6 +394,39 @@ export function useTreeData(options: TreeOptions): TreeData }), newMap); }); }, + moveBefore(key: Key, keys: Iterable) { + setItems((prevState) => { + let {items, nodeMap} = prevState; + let node = nodeMap.get(key); + if (!node) { + return prevState; + } + let toParentKey = node.parentKey ?? null; + let parent: null | TreeNode = null; + if (toParentKey != null) { + parent = nodeMap.get(toParentKey) ?? null; + } + let toIndex = parent?.children ? parent.children.indexOf(node) : items.indexOf(node); + return moveItems(prevState, keys, parent, toIndex, updateTree); + }); + }, + moveAfter(key: Key, keys: Iterable) { + setItems((prevState) => { + let {items, nodeMap} = prevState; + let node = nodeMap.get(key); + if (!node) { + return prevState; + } + let toParentKey = node.parentKey ?? null; + let parent: null | TreeNode = null; + if (toParentKey != null) { + parent = nodeMap.get(toParentKey) ?? null; + } + let toIndex = parent?.children ? parent.children.indexOf(node) : items.indexOf(node); + toIndex++; + return moveItems(prevState, keys, parent, toIndex, updateTree); + }); + }, update(oldKey: Key, newValue: T) { setItems(({items, nodeMap: originalMap}) => updateTree(items, oldKey, oldNode => { let node: TreeNode = { @@ -389,3 +443,97 @@ export function useTreeData(options: TreeOptions): TreeData } }; } + +function moveItems( + state: TreeDataState, + keys: Iterable, + toParent: TreeNode | null, + toIndex: number, + updateTree: ( + items: TreeNode[], + key: Key, + update: (node: TreeNode) => TreeNode | null, + originalMap: Map> + ) => TreeDataState +): TreeDataState { + let {items, nodeMap} = state; + + let parent = toParent; + let removeKeys = new Set(keys); + while (parent?.parentKey != null) { + if (removeKeys.has(parent.key)) { + throw new Error('Cannot move an item to be a child of itself.'); + } + parent = nodeMap.get(parent.parentKey!) ?? null; + } + + let originalToIndex = toIndex; + + let keyArray = Array.isArray(keys) ? keys : [...keys]; + // depth first search to put keys in order + let inOrderKeys: Map = new Map(); + let removedItems: Array> = []; + let newItems = items; + let newMap = nodeMap; + let i = 0; + + function traversal(node, {inorder, postorder}) { + inorder?.(node); + if (node != null) { + for (let child of node.children ?? []) { + traversal(child, {inorder, postorder}); + postorder?.(child); + } + } + } + + function inorder(child) { + // in-order so we add items as we encounter them in the tree, then we can insert them in expected order later + if (keyArray.includes(child.key)) { + inOrderKeys.set(child.key, i++); + } + } + + function postorder(child) { + // remove items and update the tree from the leaves and work upwards toward the root, this way + // we don't copy child node references from parents inadvertently + if (keyArray.includes(child.key)) { + removedItems.push({...newMap.get(child.key)!, parentKey: toParent?.key ?? null}); + let {items: nextItems, nodeMap: nextMap} = updateTree(newItems, child.key, () => null, newMap); + newItems = nextItems; + newMap = nextMap; + } + // decrement the index if the child being removed is in the target parent and before the target index + if (child.parentKey === toParent?.key + && keyArray.includes(child.key) + && (toParent?.children ? toParent.children.indexOf(child) : items.indexOf(child)) < originalToIndex) { + toIndex--; + } + } + + traversal({children: items}, {inorder, postorder}); + + let inOrderItems = removedItems.sort((a, b) => inOrderKeys.get(a.key)! > inOrderKeys.get(b.key)! ? 1 : -1); + // If parentKey is null, insert into the root. + if (!toParent || toParent.key == null) { + newMap = new Map(nodeMap); + inOrderItems.forEach(movedNode => newMap.set(movedNode.key, movedNode)); + return {items: [ + ...newItems.slice(0, toIndex), + ...inOrderItems, + ...newItems.slice(toIndex) + ], nodeMap: newMap}; + } + + // Otherwise, update the parent node and its ancestors. + return updateTree(newItems, toParent.key, parentNode => ({ + key: parentNode.key, + parentKey: parentNode.parentKey, + value: parentNode.value, + children: [ + ...parentNode.children!.slice(0, toIndex), + ...inOrderItems, + ...parentNode.children!.slice(toIndex) + ] + }), newMap); +} diff --git a/packages/@react-stately/data/test/useTreeData.test.js b/packages/@react-stately/data/test/useTreeData.test.js index ef3d7d2c071..981d33154ca 100644 --- a/packages/@react-stately/data/test/useTreeData.test.js +++ b/packages/@react-stately/data/test/useTreeData.test.js @@ -490,7 +490,7 @@ describe('useTreeData', function () { expect(result.current.items).not.toBe(initialResult.items); expect(result.current.items).toHaveLength(1); expect(result.current.items[0].value).toEqual({expanded: true, name: 'Danny'}); - expect(result.current.items[0].parentKey).toBeUndefined(); + expect(result.current.items[0].parentKey).toBeNull(); }); it('should update an item', function () { @@ -567,6 +567,19 @@ describe('useTreeData', function () { expect(result.current.items[0].children[2]).toBe(initialResult.items[0].children[2]); }); + it('should move all children of the moved target into the new map', function () { + let {result} = renderHook(() => + useTreeData({initialItems: initial, getChildren, getKey}) + ); + expect(result.current.getItem('Stacy')).toBe(result.current.items[0].children[1].children[0]); + + act(() => { + result.current.move('Sam', null, 0); + }); + + expect(result.current.getItem('Stacy')).toBe(result.current.items[0].children[0]); + }); + it('should move an item to the root', function () { let {result} = renderHook(() => useTreeData({initialItems: initial, getChildren, getKey}) @@ -693,6 +706,166 @@ describe('useTreeData', function () { expect(result.current.items.length).toEqual(initialItems.length); }); + it('should move an item within its same level before the target by key', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('David', ['Suzie']); + }); + expect(result.current.items[0].key).toEqual('Suzie'); + expect(result.current.items[1].key).toEqual('David'); + expect(result.current.items[2].key).toEqual('Emily'); + expect(result.current.items[3].key).toEqual('Eli'); + expect(result.current.items.length).toEqual(4); + expect(result.current.items[1].children[0].key).toEqual('John'); + expect(result.current.items[1].children[0].children.length).toEqual(0); + }); + + it('should move an item within its same level before the target by keys', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('David', ['John', 'Eli']); + }); + expect(result.current.items[0].key).toEqual('John'); + expect(result.current.items[1].key).toEqual('Eli'); + expect(result.current.items[2].key).toEqual('David'); + expect(result.current.items[3].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(4); + expect(result.current.items[0].children[0].key).toEqual('Suzie'); + expect(result.current.items[1].children.length).toEqual(0); + expect(result.current.items[2].children[0].key).toEqual('Sam'); + }); + + it('should move an item within its same level before the target by keys with nested items and keep tree order', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('David', ['John', 'Eli', 'Suzie']); + }); + expect(result.current.items[0].key).toEqual('John'); + expect(result.current.items[1].key).toEqual('Suzie'); + expect(result.current.items[2].key).toEqual('Eli'); + expect(result.current.items[3].key).toEqual('David'); + expect(result.current.items[4].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(5); + expect(result.current.items[0].children.length).toEqual(0); + expect(result.current.items[1].children.length).toEqual(0); + expect(result.current.items[2].children.length).toEqual(0); + expect(result.current.items[3].children[0].key).toEqual('Sam'); + }); + + it('should move items down the tree', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('Suzie', ['Sam', 'Eli']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(2); + expect(result.current.items[0].children.length).toEqual(2); + expect(result.current.items[1].children.length).toEqual(0); + expect(result.current.items[0].children[0].children[0].key).toEqual('Sam'); + expect(result.current.items[0].children[0].children[1].key).toEqual('Eli'); + expect(result.current.items[0].children[0].children[2].key).toEqual('Suzie'); + expect(result.current.items[0].children[0].children.length).toEqual(3); + }); + + describe('moveBefore error', function () { + const consoleError = console.error; + beforeEach(() => { + console.error = jest.fn(); + }); + + afterEach(() => { + console.error = consoleError; + }); + + it('cannot move an item within itself', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + try { + act(() => result.current.moveBefore('Suzie', ['John', 'Sam', 'Eli'])); + } catch (e) { + expect(e.toString()).toContain('Cannot move an item to be a child of itself.'); + } + }); + }); + + it('should move an item within its same level after the target by key', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveAfter('David', ['Suzie']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Suzie'); + expect(result.current.items[2].key).toEqual('Emily'); + expect(result.current.items[3].key).toEqual('Eli'); + expect(result.current.items.length).toEqual(4); + expect(result.current.items[0].children[0].key).toEqual('John'); + expect(result.current.items[0].children[0].children.length).toEqual(0); + }); + + it('should move an item within its same level after the target by keys', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveAfter('David', ['John', 'Eli']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('John'); + expect(result.current.items[2].key).toEqual('Eli'); + expect(result.current.items[3].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(4); + expect(result.current.items[0].children[0].key).toEqual('Sam'); + expect(result.current.items[1].children[0].key).toEqual('Suzie'); + expect(result.current.items[2].children.length).toEqual(0); + }); + + it('should move an item within its same level after the target by keys with nested items and keep tree order', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveAfter('David', ['John', 'Eli', 'Suzie']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('John'); + expect(result.current.items[2].key).toEqual('Suzie'); + expect(result.current.items[3].key).toEqual('Eli'); + expect(result.current.items[4].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(5); + expect(result.current.items[0].children[0].key).toEqual('Sam'); + expect(result.current.items[1].children.length).toEqual(0); + expect(result.current.items[2].children.length).toEqual(0); + expect(result.current.items[3].children.length).toEqual(0); + }); + it('should move an item to a different level before the target', function () { const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; @@ -709,6 +882,165 @@ describe('useTreeData', function () { expect(result.current.items.length).toEqual(2); }); + it('can move an item multiple times', function () { + const initialItems = [...initial, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveAfter('Eli', ['David']); + }); + expect(result.current.items[0].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('David'); + act(() => { + result.current.moveAfter('David', ['Eli']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Eli'); + act(() => { + result.current.moveAfter('Eli', ['David']); + }); + expect(result.current.items[0].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('David'); + act(() => { + result.current.moveAfter('David', ['Eli']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Eli'); + + // do the same with moveBefore + act(() => { + result.current.moveBefore('David', ['Eli']); + }); + expect(result.current.items[0].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('David'); + act(() => { + result.current.moveBefore('Eli', ['David']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Eli'); + act(() => { + result.current.moveBefore('David', ['Eli']); + }); + expect(result.current.items[0].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('David'); + act(() => { + result.current.moveBefore('Eli', ['David']); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[1].key).toEqual('Eli'); + }); + + it('can move an item within its same level', function () { + const initialItems = [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]}, + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren: (item) => item.childItems, getKey: (item) => item.id}) + ); + act(() => { + result.current.moveAfter('project-3', ['project-2']); + }); + expect(result.current.items[0].key).toEqual('projects'); + expect(result.current.items[0].children[0].key).toEqual('project-1'); + expect(result.current.items[0].children[1].key).toEqual('project-3'); + expect(result.current.items[0].children[2].key).toEqual('project-2'); + + // move again to the same place + act(() => { + result.current.moveAfter('project-3', ['project-2']); + }); + expect(result.current.items[0].key).toEqual('projects'); + expect(result.current.items[0].children[0].key).toEqual('project-1'); + expect(result.current.items[0].children[1].key).toEqual('project-3'); + expect(result.current.items[0].children[2].key).toEqual('project-2'); + + // move to a different place + act(() => { + result.current.moveAfter('project-4', ['project-2']); + }); + expect(result.current.items[0].key).toEqual('projects'); + expect(result.current.items[0].children[0].key).toEqual('project-1'); + expect(result.current.items[0].children[1].key).toEqual('project-3'); + expect(result.current.items[0].children[2].key).toEqual('project-4'); + expect(result.current.items[0].children[3].key).toEqual('project-2'); + }); + + + it('can move an item to a different level', function () { + const initialItems = [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]}, + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren: (item) => item.childItems, getKey: (item) => item.id}) + ); + act(() => { + result.current.moveBefore('project-2B', ['project-3']); + }); + expect(result.current.items[0].key).toEqual('projects'); + expect(result.current.items[0].children[0].key).toEqual('project-1'); + expect(result.current.items[0].children[1].key).toEqual('project-2'); + + expect(result.current.items[0].children[1].children[0].key).toEqual('project-2A'); + expect(result.current.items[0].children[1].children[1].key).toEqual('project-3'); + expect(result.current.items[0].children[1].children[2].key).toEqual('project-2B'); + }); + it('should move an item to a different level after the target', function () { const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; let {result} = renderHook(() =>