@snack-uikit/tree 0.10.1 → 0.10.2-preview-13f87e77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +17 -0
  2. package/dist/cjs/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.d.ts +1 -0
  3. package/dist/cjs/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.js +80 -0
  4. package/dist/cjs/helpers/__tests__/setChildrenOfTreeNode.spec.d.ts +1 -0
  5. package/dist/cjs/helpers/__tests__/setChildrenOfTreeNode.spec.js +102 -0
  6. package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.d.ts +2 -0
  7. package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.js +17 -0
  8. package/dist/cjs/helpers/index.d.ts +2 -0
  9. package/dist/cjs/helpers/index.js +3 -1
  10. package/dist/cjs/helpers/setChildrenOfTreeNode.d.ts +2 -0
  11. package/dist/cjs/helpers/setChildrenOfTreeNode.js +28 -0
  12. package/dist/cjs/helpers/sortTreeItemsByTitle.js +2 -2
  13. package/dist/cjs/helpers/traverse.d.ts +6 -0
  14. package/dist/cjs/helpers/traverse.js +38 -2
  15. package/dist/cjs/hooks/index.d.ts +2 -0
  16. package/dist/cjs/hooks/index.js +26 -0
  17. package/dist/cjs/hooks/useTreeMultiSelection.d.ts +12 -0
  18. package/dist/cjs/hooks/useTreeMultiSelection.js +82 -0
  19. package/dist/cjs/hooks/useTreeWithPreload.d.ts +33 -0
  20. package/dist/cjs/hooks/useTreeWithPreload.js +138 -0
  21. package/dist/cjs/index.d.ts +1 -0
  22. package/dist/cjs/index.js +2 -1
  23. package/dist/cjs/types.d.ts +12 -0
  24. package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.d.ts +1 -0
  25. package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.js +64 -0
  26. package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.d.ts +1 -0
  27. package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.js +50 -0
  28. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.d.ts +2 -0
  29. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.js +11 -0
  30. package/dist/esm/helpers/index.d.ts +2 -0
  31. package/dist/esm/helpers/index.js +2 -0
  32. package/dist/esm/helpers/setChildrenOfTreeNode.d.ts +2 -0
  33. package/dist/esm/helpers/setChildrenOfTreeNode.js +20 -0
  34. package/dist/esm/helpers/sortTreeItemsByTitle.js +2 -2
  35. package/dist/esm/helpers/traverse.d.ts +6 -0
  36. package/dist/esm/helpers/traverse.js +24 -0
  37. package/dist/esm/hooks/index.d.ts +2 -0
  38. package/dist/esm/hooks/index.js +2 -0
  39. package/dist/esm/hooks/useTreeMultiSelection.d.ts +12 -0
  40. package/dist/esm/hooks/useTreeMultiSelection.js +43 -0
  41. package/dist/esm/hooks/useTreeWithPreload.d.ts +33 -0
  42. package/dist/esm/hooks/useTreeWithPreload.js +98 -0
  43. package/dist/esm/index.d.ts +1 -0
  44. package/dist/esm/index.js +1 -0
  45. package/dist/esm/types.d.ts +12 -0
  46. package/package.json +4 -2
  47. package/src/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.ts +88 -0
  48. package/src/helpers/__tests__/setChildrenOfTreeNode.spec.ts +68 -0
  49. package/src/helpers/collectEmptyNestedNodesInExpanded.ts +16 -0
  50. package/src/helpers/index.ts +2 -0
  51. package/src/helpers/setChildrenOfTreeNode.ts +30 -0
  52. package/src/helpers/sortTreeItemsByTitle.ts +2 -2
  53. package/src/helpers/traverse.ts +37 -0
  54. package/src/hooks/index.ts +2 -0
  55. package/src/hooks/useTreeMultiSelection.ts +61 -0
  56. package/src/hooks/useTreeWithPreload.ts +150 -0
  57. package/src/index.ts +1 -0
  58. package/src/types.ts +9 -0
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { TreeNodeProps } from '../../types';
4
+ import { setChildrenOfTreeNode } from '../setChildrenOfTreeNode';
5
+
6
+ const node = (id: string, nested?: { id: string; title: string }[]) => ({
7
+ id,
8
+ title: id,
9
+ ...(nested !== undefined && { nested: nested.map(n => ({ ...n, title: n.id })) }),
10
+ });
11
+
12
+ describe('setChildrenOfTreeNode', () => {
13
+ it('replaces nested for root-level node', () => {
14
+ const tree = [node('a', [{ id: 'a1', title: 'a1' }]), node('b', [{ id: 'b1', title: 'b1' }])];
15
+ const children = [node('new1'), node('new2')];
16
+ const result = setChildrenOfTreeNode(tree, 'b', children);
17
+
18
+ expect(result).toHaveLength(2);
19
+ expect(result[0]).toMatchObject({ id: 'a', nested: [{ id: 'a1' }] });
20
+ expect(result[1]).toMatchObject({ id: 'b', nested: [{ id: 'new1' }, { id: 'new2' }] });
21
+ });
22
+
23
+ it('replaces nested for deeply nested node', () => {
24
+ const childWithNested = node('child');
25
+ (childWithNested as { nested: { id: string; title: string }[] }).nested = [{ id: 'grand', title: 'grand' }];
26
+ const tree = [node('root', [childWithNested])];
27
+ const children = [node('replacement')];
28
+ const result = setChildrenOfTreeNode(tree, 'child', children);
29
+
30
+ const root = result[0] as TreeNodeProps & { nested: (TreeNodeProps & { nested: TreeNodeProps[] })[] };
31
+ expect(root.nested).toHaveLength(1);
32
+ expect(root.nested[0]).toMatchObject({ id: 'child', nested: [{ id: 'replacement' }] });
33
+ });
34
+
35
+ it('returns cloned tree when node is not found', () => {
36
+ const tree = [node('a'), node('b')];
37
+ const result = setChildrenOfTreeNode(tree, 'missing', [node('x')]);
38
+
39
+ expect(result).toHaveLength(2);
40
+ expect(result[0]).toMatchObject({ id: 'a' });
41
+ expect(result[1]).toMatchObject({ id: 'b' });
42
+ expect(result).not.toBe(tree);
43
+ });
44
+
45
+ it('returns empty array for empty tree', () => {
46
+ const result = setChildrenOfTreeNode([], 'any', [node('x')]);
47
+ expect(result).toEqual([]);
48
+ });
49
+
50
+ it('sets children for target node that had no nested (leaf)', () => {
51
+ const tree = [node('leaf'), node('other')];
52
+ const children = [node('new1')];
53
+ const result = setChildrenOfTreeNode(tree, 'leaf', children);
54
+
55
+ expect(result[0]).toMatchObject({ id: 'leaf', nested: [{ id: 'new1' }] });
56
+ expect(result[1]).toMatchObject({ id: 'other' });
57
+ });
58
+
59
+ it('preserves order of root nodes when target is second', () => {
60
+ const tree = [node('first'), node('second', [{ id: 's1', title: 's1' }]), node('third')];
61
+ const children = [node('new1'), node('new2')];
62
+ const result = setChildrenOfTreeNode(tree, 'second', children);
63
+
64
+ expect(result.map(n => n.id)).toEqual(['first', 'second', 'third']);
65
+ expect((result[1] as TreeNodeProps & { nested: TreeNodeProps[] }).nested).toHaveLength(2);
66
+ expect((result[1] as { nested: { id: string }[] }).nested.map(n => n.id)).toEqual(['new1', 'new2']);
67
+ });
68
+ });
@@ -0,0 +1,16 @@
1
+ import { TreeNodeProps } from '../types';
2
+ import { traverse } from './traverse';
3
+
4
+ export function collectEmptyNestedNodesInExpanded<TTreeNode extends TreeNodeProps>(
5
+ nodes: TTreeNode[],
6
+ expandedIds: Set<string>,
7
+ ): TTreeNode[] {
8
+ const result: TTreeNode[] = [];
9
+ traverse(nodes, node => {
10
+ const hasEmptyNested = Array.isArray(node.nested) && node.nested.length === 0;
11
+ if (hasEmptyNested && expandedIds.has(node.id)) {
12
+ result.push(node);
13
+ }
14
+ });
15
+ return result;
16
+ }
@@ -6,3 +6,5 @@ export * from './getSearchedTreeNodeById';
6
6
  export * from './lookupTreeForSelectedNodes';
7
7
  export * from './sortTreeItemsByTitle';
8
8
  export * from './traverse';
9
+ export * from './setChildrenOfTreeNode';
10
+ export * from './collectEmptyNestedNodesInExpanded';
@@ -0,0 +1,30 @@
1
+ import { TreeNodeProps } from '../types';
2
+ import { traverseWithTarget } from './traverse';
3
+
4
+ export const setChildrenOfTreeNode = <TTreeNode extends TreeNodeProps>(
5
+ tree: TTreeNode[],
6
+ nodeId: string,
7
+ children: TTreeNode[],
8
+ ): TTreeNode[] => {
9
+ const result: TTreeNode[] = [];
10
+
11
+ traverseWithTarget(tree, result, (source, _depth, targetList) => {
12
+ const isTarget = source.id === nodeId;
13
+ const hasNested = Array.isArray(source.nested);
14
+
15
+ let newNested: TTreeNode[] | undefined;
16
+ if (isTarget) {
17
+ newNested = children;
18
+ } else if (hasNested) {
19
+ newNested = [];
20
+ }
21
+
22
+ const newNode = (newNested !== undefined ? { ...source, nested: newNested } : { ...source }) as TTreeNode;
23
+
24
+ targetList.push(newNode);
25
+
26
+ return !isTarget && hasNested && source.nested?.length ? newNested : undefined;
27
+ });
28
+
29
+ return result;
30
+ };
@@ -3,8 +3,8 @@ import { extractTreeNodeTitle } from './extractTreeNodeTitle';
3
3
 
4
4
  export const sortTreeItemsByTitle = (items: ExtendedTreeNodeProps[]) =>
5
5
  items?.toSorted((itemA, itemB) => {
6
- const valueA = extractTreeNodeTitle(itemA);
7
- const valueB = extractTreeNodeTitle(itemB);
6
+ const valueA = extractTreeNodeTitle(itemA).toLowerCase();
7
+ const valueB = extractTreeNodeTitle(itemB).toLowerCase();
8
8
 
9
9
  return valueA.localeCompare(valueB);
10
10
  });
@@ -4,6 +4,12 @@ import { TreeNodeProps } from '../';
4
4
 
5
5
  type NodeWithDepth<T extends TreeNodeProps> = { node: T; depth: number };
6
6
 
7
+ type NodeWithDepthAndTarget<T extends TreeNodeProps> = {
8
+ node: T;
9
+ depth: number;
10
+ targetList: T[];
11
+ };
12
+
7
13
  export const traverse = <T extends TreeNodeProps>(nodes: T[], callback: (node: T, depth: number) => void) => {
8
14
  const queue = new Queue<NodeWithDepth<T>>();
9
15
 
@@ -25,3 +31,34 @@ export const traverse = <T extends TreeNodeProps>(nodes: T[], callback: (node: T
25
31
  }
26
32
  }
27
33
  };
34
+
35
+ /**
36
+ * BFS с указанием целевого списка для каждого узла.
37
+ * Очередь хранит (node, depth, targetList). Callback добавляет узел в targetList
38
+ * и возвращает массив для детей (или undefined, чтобы не обходить детей).
39
+ */
40
+ export const traverseWithTarget = <T extends TreeNodeProps>(
41
+ nodes: T[],
42
+ rootTargetList: T[],
43
+ callback: (node: T, depth: number, targetList: T[]) => T[] | undefined,
44
+ ) => {
45
+ const queue = new Queue<NodeWithDepthAndTarget<T>>();
46
+
47
+ for (const node of nodes) {
48
+ queue.enqueue({ node, depth: 0, targetList: rootTargetList });
49
+ }
50
+
51
+ while (!queue.isEmpty()) {
52
+ const item = queue.dequeue();
53
+ if (!item) continue;
54
+
55
+ const { node, depth, targetList } = item;
56
+ const childTargetList = callback(node, depth, targetList);
57
+
58
+ if (childTargetList !== undefined && node.nested?.length) {
59
+ for (const child of node.nested) {
60
+ queue.enqueue({ node: child as T, depth: depth + 1, targetList: childTargetList });
61
+ }
62
+ }
63
+ }
64
+ };
@@ -0,0 +1,2 @@
1
+ export * from './useTreeWithPreload';
2
+ export * from './useTreeMultiSelection';
@@ -0,0 +1,61 @@
1
+ import { useCallback } from 'react';
2
+
3
+ import { useValueControl } from '@snack-uikit/utils';
4
+
5
+ import { PreloadNodeHandler, SelectHandler, TreeNodeProps } from '../types';
6
+
7
+ type UseTreeMultiSelectionParams<TTreeNode extends TreeNodeProps> = {
8
+ onDataLoad: PreloadNodeHandler<TTreeNode>;
9
+ onSelect: SelectHandler;
10
+ selected?: string[];
11
+ onChangeSelected?: (newSelected: string[]) => void;
12
+ };
13
+
14
+ const getNewSelectedIds = (selectedIds: string[], added: string[], removed: string[]): string[] => {
15
+ const result = new Set(selectedIds);
16
+
17
+ added.forEach(id => {
18
+ result.add(id);
19
+ });
20
+
21
+ removed.forEach(id => {
22
+ result.delete(id);
23
+ });
24
+
25
+ return Array.from(result);
26
+ };
27
+
28
+ export function useTreeMultiSelection<TTreeNode extends TreeNodeProps>({
29
+ onDataLoad,
30
+ onSelect: onSelectProp,
31
+ selected,
32
+ onChangeSelected,
33
+ }: UseTreeMultiSelectionParams<TTreeNode>) {
34
+ const [selectedIds = [], setSelectedIds] = useValueControl<string[]>({
35
+ value: selected,
36
+ defaultValue: [],
37
+ onChange: onChangeSelected,
38
+ });
39
+
40
+ const onSelect = useCallback(
41
+ async (selectedKeys: string[], node: TreeNodeProps) => {
42
+ const isSelected = selectedKeys.includes(node.id);
43
+ const clonedNode = structuredClone(node);
44
+
45
+ if (node.nested && !node.nested.length) {
46
+ const { preloadedChildren } = await onDataLoad(node as TTreeNode);
47
+ clonedNode.nested = preloadedChildren;
48
+ }
49
+
50
+ const { added, removed } = onSelectProp({ selectedKeys, node: clonedNode, isSelected });
51
+ const updatedSelectedIds = getNewSelectedIds(selectedIds, added, removed);
52
+ setSelectedIds(updatedSelectedIds);
53
+ },
54
+ [onDataLoad, onSelectProp, selectedIds, setSelectedIds],
55
+ );
56
+
57
+ return {
58
+ selected: selectedIds,
59
+ onSelect,
60
+ };
61
+ }
@@ -0,0 +1,150 @@
1
+ import { useDebounceValue, useDidUpdate, useRefState } from '@siberiacancode/reactuse';
2
+ import { cancelable, CancelablePromise } from 'cancelable-promise';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import { collectEmptyNestedNodesInExpanded, setChildrenOfTreeNode, traverse } from '../helpers';
6
+ import { TreeNodeProps } from '../types';
7
+
8
+ export type SearchResult<TTreeNode extends TreeNodeProps> = {
9
+ tree: TTreeNode[];
10
+ needPreloadNodes: string[];
11
+ };
12
+
13
+ export type SearchParams = {
14
+ search: string;
15
+ expandedNodes: string[];
16
+ };
17
+
18
+ type UseTreeWithPreloadParams<TRecordValue, TTreeNode extends TreeNodeProps> = {
19
+ initTree: TTreeNode[];
20
+ onPreloadNode: (node: TreeNodeProps) => Promise<TTreeNode[]>;
21
+ onPreloadNodes: (nodes: string[]) => Promise<Record<string, TTreeNode[]>>;
22
+ onSearch: (params: SearchParams) => Promise<SearchResult<TTreeNode>>;
23
+ mapNodeToRecordItem: (node: TTreeNode) => TRecordValue;
24
+ };
25
+
26
+ export function useTreeWithPreload<TRecordValue, TTreeNode extends TreeNodeProps>({
27
+ initTree,
28
+ onPreloadNode,
29
+ onPreloadNodes,
30
+ onSearch,
31
+ mapNodeToRecordItem,
32
+ }: UseTreeWithPreloadParams<TRecordValue, TTreeNode>) {
33
+ const tree = useRefState<TTreeNode[]>(initTree);
34
+ const treeItemsRecord = useRefState<Record<string, TRecordValue>>({});
35
+
36
+ const expandedNodes = useRefState<string[]>([]);
37
+
38
+ const [search, setSearch] = useState<string>('');
39
+ const debouncedSearch = useDebounceValue(search, 500);
40
+
41
+ const [loading, setLoading] = useState(false);
42
+ const searchPromiseRef = useRef<CancelablePromise<SearchResult<TTreeNode>> | null>(null);
43
+
44
+ const buildTreeItemsRecord = useCallback(
45
+ (nodes: TTreeNode[]): Record<string, TRecordValue> => {
46
+ const record: Record<string, TRecordValue> = {};
47
+
48
+ traverse<TTreeNode>(nodes, node => {
49
+ record[node.id] = mapNodeToRecordItem(node);
50
+ });
51
+
52
+ return record;
53
+ },
54
+ [mapNodeToRecordItem],
55
+ );
56
+
57
+ const onExpand = useCallback(
58
+ (nodes: string[]) => {
59
+ expandedNodes.current = nodes;
60
+ },
61
+ [expandedNodes],
62
+ );
63
+
64
+ const onDataLoad = useCallback(
65
+ async (node: TreeNodeProps) => {
66
+ const preloadedChildren = await onPreloadNode(node);
67
+ const updatedTree = setChildrenOfTreeNode(tree.current, node.id, preloadedChildren);
68
+ tree.current = updatedTree;
69
+
70
+ const newTreeItemsRecord: Record<string, TRecordValue> = { ...treeItemsRecord.current };
71
+ traverse<TTreeNode>(preloadedChildren, child => {
72
+ newTreeItemsRecord[child.id] = mapNodeToRecordItem(child);
73
+ });
74
+ treeItemsRecord.current = newTreeItemsRecord;
75
+
76
+ return { preloadedChildren, updatedTree, newTreeItemsRecord };
77
+ },
78
+ [mapNodeToRecordItem, onPreloadNode, tree, treeItemsRecord],
79
+ );
80
+
81
+ const handleSearch = useCallback(
82
+ async (searchQuery: string) => {
83
+ searchPromiseRef.current?.cancel();
84
+
85
+ setLoading(true);
86
+ const searchPromise = cancelable(onSearch({ search: searchQuery, expandedNodes: expandedNodes.current }));
87
+ searchPromiseRef.current = searchPromise;
88
+
89
+ try {
90
+ const { tree: searchedTree, needPreloadNodes } = await searchPromise;
91
+ if (!searchPromise.isCanceled()) {
92
+ tree.current = searchedTree;
93
+ treeItemsRecord.current = buildTreeItemsRecord(searchedTree);
94
+
95
+ const expandedSet = new Set(expandedNodes.current);
96
+ const toPreloadExpandableNodes = collectEmptyNestedNodesInExpanded(searchedTree, expandedSet);
97
+ const collectedNodesForPreload = Array.from(
98
+ new Set([...toPreloadExpandableNodes.map(node => node.id), ...needPreloadNodes]),
99
+ );
100
+
101
+ if (!collectedNodesForPreload.length) {
102
+ return;
103
+ }
104
+
105
+ const preloadedNodes = await onPreloadNodes(collectedNodesForPreload);
106
+
107
+ if (searchPromiseRef.current !== searchPromise) {
108
+ return;
109
+ }
110
+
111
+ let tmpTree = [...searchedTree];
112
+ for (const [nodeId, children] of Object.entries(preloadedNodes)) {
113
+ tmpTree = setChildrenOfTreeNode(tmpTree, nodeId, children);
114
+ }
115
+
116
+ tree.current = tmpTree;
117
+ treeItemsRecord.current = buildTreeItemsRecord(tmpTree);
118
+ }
119
+ } finally {
120
+ if (searchPromiseRef.current === searchPromise) {
121
+ setLoading(false);
122
+ searchPromiseRef.current = null;
123
+ }
124
+ }
125
+ },
126
+ [buildTreeItemsRecord, expandedNodes, onPreloadNodes, onSearch, tree, treeItemsRecord],
127
+ );
128
+
129
+ useDidUpdate(() => {
130
+ handleSearch(debouncedSearch);
131
+ }, [debouncedSearch, handleSearch]);
132
+
133
+ useEffect(() => {
134
+ tree.current = initTree;
135
+ treeItemsRecord.current = buildTreeItemsRecord(initTree);
136
+ }, [buildTreeItemsRecord, initTree, tree, treeItemsRecord]);
137
+
138
+ return {
139
+ tree,
140
+ expandedNodes,
141
+ loading,
142
+ treeItemsRecord,
143
+ search: {
144
+ value: search,
145
+ onChange: setSearch,
146
+ },
147
+ onExpand,
148
+ onDataLoad,
149
+ };
150
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './components';
2
2
  export type { OnNodeClick, TreeNodeId, TreeNodeProps } from './types';
3
3
  export { setNonce } from '@snack-uikit/list';
4
+ export * from './hooks';
package/src/types.ts CHANGED
@@ -118,3 +118,12 @@ export type TreeBaseProps = TreeView | TreeMultiSelect | TreeSingleSelect;
118
118
  export type ExtendedTreeNodeProps = TreeNodeProps & {
119
119
  getTitle?(): void;
120
120
  };
121
+
122
+ export type PreloadNodeHandler<TTreeNode extends TreeNodeProps> = (
123
+ node: TreeNodeProps,
124
+ ) => Promise<{ preloadedChildren: TTreeNode[]; updatedTree: TTreeNode[] }>;
125
+
126
+ export type SelectHandler = (props: { selectedKeys: string[]; node: TreeNodeProps; isSelected: boolean }) => {
127
+ added: string[];
128
+ removed: string[];
129
+ };