@snack-uikit/tree 0.11.0 → 0.11.1-preview-c3fee040.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 (63) hide show
  1. package/README.md +12 -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 +7 -1
  14. package/dist/cjs/helpers/traverse.js +38 -2
  15. package/dist/cjs/hooks/__tests__/useSearchableTree.spec.d.ts +1 -0
  16. package/dist/cjs/hooks/__tests__/useSearchableTree.spec.js +230 -0
  17. package/dist/cjs/hooks/__tests__/useTreeMultiSelection.spec.d.ts +1 -0
  18. package/dist/cjs/hooks/__tests__/useTreeMultiSelection.spec.js +225 -0
  19. package/dist/cjs/hooks/index.d.ts +2 -0
  20. package/dist/cjs/hooks/index.js +26 -0
  21. package/dist/cjs/hooks/useSearchableTree.d.ts +28 -0
  22. package/dist/cjs/hooks/useSearchableTree.js +147 -0
  23. package/dist/cjs/hooks/useTreeMultiSelection.d.ts +12 -0
  24. package/dist/cjs/hooks/useTreeMultiSelection.js +82 -0
  25. package/dist/cjs/types.d.ts +17 -0
  26. package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.d.ts +1 -0
  27. package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.js +64 -0
  28. package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.d.ts +1 -0
  29. package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.js +50 -0
  30. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.d.ts +2 -0
  31. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.js +11 -0
  32. package/dist/esm/helpers/index.d.ts +2 -0
  33. package/dist/esm/helpers/index.js +2 -0
  34. package/dist/esm/helpers/setChildrenOfTreeNode.d.ts +2 -0
  35. package/dist/esm/helpers/setChildrenOfTreeNode.js +20 -0
  36. package/dist/esm/helpers/sortTreeItemsByTitle.js +2 -2
  37. package/dist/esm/helpers/traverse.d.ts +7 -1
  38. package/dist/esm/helpers/traverse.js +24 -0
  39. package/dist/esm/hooks/__tests__/useSearchableTree.spec.d.ts +1 -0
  40. package/dist/esm/hooks/__tests__/useSearchableTree.spec.js +165 -0
  41. package/dist/esm/hooks/__tests__/useTreeMultiSelection.spec.d.ts +1 -0
  42. package/dist/esm/hooks/__tests__/useTreeMultiSelection.spec.js +130 -0
  43. package/dist/esm/hooks/index.d.ts +2 -0
  44. package/dist/esm/hooks/index.js +2 -0
  45. package/dist/esm/hooks/useSearchableTree.d.ts +28 -0
  46. package/dist/esm/hooks/useSearchableTree.js +108 -0
  47. package/dist/esm/hooks/useTreeMultiSelection.d.ts +12 -0
  48. package/dist/esm/hooks/useTreeMultiSelection.js +43 -0
  49. package/dist/esm/types.d.ts +17 -0
  50. package/package.json +22 -4
  51. package/src/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.ts +88 -0
  52. package/src/helpers/__tests__/setChildrenOfTreeNode.spec.ts +68 -0
  53. package/src/helpers/collectEmptyNestedNodesInExpanded.ts +16 -0
  54. package/src/helpers/index.ts +2 -0
  55. package/src/helpers/setChildrenOfTreeNode.ts +30 -0
  56. package/src/helpers/sortTreeItemsByTitle.ts +2 -2
  57. package/src/helpers/traverse.ts +38 -1
  58. package/src/hooks/__tests__/useSearchableTree.spec.ts +200 -0
  59. package/src/hooks/__tests__/useTreeMultiSelection.spec.ts +165 -0
  60. package/src/hooks/index.ts +2 -0
  61. package/src/hooks/useSearchableTree.ts +163 -0
  62. package/src/hooks/useTreeMultiSelection.ts +61 -0
  63. package/src/types.ts +15 -0
@@ -0,0 +1,11 @@
1
+ import { traverse } from './traverse';
2
+ export function collectEmptyNestedNodesInExpanded(nodes, expandedIds) {
3
+ const result = [];
4
+ traverse(nodes, node => {
5
+ const hasEmptyNested = Array.isArray(node.nested) && node.nested.length === 0;
6
+ if (hasEmptyNested && expandedIds.has(node.id)) {
7
+ result.push(node);
8
+ }
9
+ });
10
+ return result;
11
+ }
@@ -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';
@@ -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,2 @@
1
+ import { TreeNodeProps } from '../types';
2
+ export declare const setChildrenOfTreeNode: <TTreeNode extends TreeNodeProps>(tree: TTreeNode[], nodeId: string, children: TTreeNode[]) => TTreeNode[];
@@ -0,0 +1,20 @@
1
+ import { traverseWithTarget } from './traverse';
2
+ export const setChildrenOfTreeNode = (tree, nodeId, children) => {
3
+ const result = [];
4
+ traverseWithTarget(tree, result, (source, _depth, targetList) => {
5
+ var _a;
6
+ const isTarget = source.id === nodeId;
7
+ const hasNested = Array.isArray(source.nested);
8
+ let newNested;
9
+ if (isTarget) {
10
+ newNested = children;
11
+ }
12
+ else if (hasNested) {
13
+ newNested = [];
14
+ }
15
+ const newNode = (newNested !== undefined ? Object.assign(Object.assign({}, source), { nested: newNested }) : Object.assign({}, source));
16
+ targetList.push(newNode);
17
+ return !isTarget && hasNested && ((_a = source.nested) === null || _a === void 0 ? void 0 : _a.length) ? newNested : undefined;
18
+ });
19
+ return result;
20
+ };
@@ -1,6 +1,6 @@
1
1
  import { extractTreeNodeTitle } from './extractTreeNodeTitle';
2
2
  export const sortTreeItemsByTitle = (items) => items === null || items === void 0 ? void 0 : items.toSorted((itemA, itemB) => {
3
- const valueA = extractTreeNodeTitle(itemA);
4
- const valueB = extractTreeNodeTitle(itemB);
3
+ const valueA = extractTreeNodeTitle(itemA).toLowerCase();
4
+ const valueB = extractTreeNodeTitle(itemB).toLowerCase();
5
5
  return valueA.localeCompare(valueB);
6
6
  });
@@ -1,2 +1,8 @@
1
- import { TreeNodeProps } from '../';
1
+ import { TreeNodeProps } from '../types';
2
2
  export declare const traverse: <T extends TreeNodeProps>(nodes: T[], callback: (node: T, depth: number) => void) => void;
3
+ /**
4
+ * BFS с указанием целевого списка для каждого узла.
5
+ * Очередь хранит (node, depth, targetList). Callback добавляет узел в targetList
6
+ * и возвращает массив для детей (или undefined, чтобы не обходить детей).
7
+ */
8
+ export declare const traverseWithTarget: <T extends TreeNodeProps>(nodes: T[], rootTargetList: T[], callback: (node: T, depth: number, targetList: T[]) => T[] | undefined) => void;
@@ -17,3 +17,27 @@ export const traverse = (nodes, callback) => {
17
17
  }
18
18
  }
19
19
  };
20
+ /**
21
+ * BFS с указанием целевого списка для каждого узла.
22
+ * Очередь хранит (node, depth, targetList). Callback добавляет узел в targetList
23
+ * и возвращает массив для детей (или undefined, чтобы не обходить детей).
24
+ */
25
+ export const traverseWithTarget = (nodes, rootTargetList, callback) => {
26
+ var _a;
27
+ const queue = new Queue();
28
+ for (const node of nodes) {
29
+ queue.enqueue({ node, depth: 0, targetList: rootTargetList });
30
+ }
31
+ while (!queue.isEmpty()) {
32
+ const item = queue.dequeue();
33
+ if (!item)
34
+ continue;
35
+ const { node, depth, targetList } = item;
36
+ const childTargetList = callback(node, depth, targetList);
37
+ if (childTargetList !== undefined && ((_a = node.nested) === null || _a === void 0 ? void 0 : _a.length)) {
38
+ for (const child of node.nested) {
39
+ queue.enqueue({ node: child, depth: depth + 1, targetList: childTargetList });
40
+ }
41
+ }
42
+ }
43
+ };
@@ -0,0 +1,165 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { act, renderHook, waitFor } from '@testing-library/react';
11
+ import { useEffect, useRef } from 'react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { useSearchableTree } from '../useSearchableTree';
14
+ vi.mock('@siberiacancode/reactuse', () => ({
15
+ useDebounceValue: (value) => value,
16
+ useDidUpdate: (effect, deps) => {
17
+ const isFirstRenderRef = useRef(true);
18
+ useEffect(() => {
19
+ if (isFirstRenderRef.current) {
20
+ isFirstRenderRef.current = false;
21
+ return;
22
+ }
23
+ effect();
24
+ }, [deps, effect]);
25
+ },
26
+ useRefState: (initialValue) => useRef(initialValue),
27
+ }));
28
+ const createLeaf = (id) => ({
29
+ id,
30
+ title: id,
31
+ });
32
+ const createParent = (id, nested) => ({
33
+ id,
34
+ title: id,
35
+ nested,
36
+ });
37
+ const mapNodeToRecordItem = (node) => ({
38
+ label: String(node.title),
39
+ });
40
+ describe('useSearchableTree', () => {
41
+ it('should initialize tree and treeItemsRecord from initTree', () => {
42
+ const initTree = [createLeaf('node-1'), createParent('node-2', [createLeaf('node-2-1')])];
43
+ const { result } = renderHook(() => useSearchableTree({
44
+ initTree,
45
+ onPreloadNode: vi.fn(),
46
+ onPreloadNodes: vi.fn(),
47
+ onSearch: vi.fn(),
48
+ mapNodeToRecordItem,
49
+ }));
50
+ expect(result.current.tree.current).toEqual(initTree);
51
+ expect(result.current.treeItemsRecord.current).toEqual({
52
+ 'node-1': { label: 'node-1' },
53
+ 'node-2': { label: 'node-2' },
54
+ 'node-2-1': { label: 'node-2-1' },
55
+ });
56
+ });
57
+ it('should update expandedNodes on onExpand call', () => {
58
+ const { result } = renderHook(() => useSearchableTree({
59
+ initTree: [createLeaf('root')],
60
+ onPreloadNode: vi.fn(),
61
+ onPreloadNodes: vi.fn(),
62
+ onSearch: vi.fn(),
63
+ mapNodeToRecordItem,
64
+ }));
65
+ act(() => {
66
+ result.current.onExpand(['root', 'child']);
67
+ });
68
+ expect(result.current.expandedNodes.current).toEqual(['root', 'child']);
69
+ });
70
+ it('should preload node children and update tree and record on onDataLoad', () => __awaiter(void 0, void 0, void 0, function* () {
71
+ const initTree = [createParent('root', [])];
72
+ const preloadedChildren = [createLeaf('child-1'), createLeaf('child-2')];
73
+ const onPreloadNode = vi.fn().mockResolvedValue(preloadedChildren);
74
+ const { result } = renderHook(() => useSearchableTree({
75
+ initTree,
76
+ onPreloadNode,
77
+ onPreloadNodes: vi.fn(),
78
+ onSearch: vi.fn(),
79
+ mapNodeToRecordItem,
80
+ }));
81
+ let loadResult;
82
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
83
+ loadResult = yield result.current.onDataLoad(createParent('root', []));
84
+ }));
85
+ expect(onPreloadNode).toHaveBeenCalledWith(createParent('root', []));
86
+ expect(loadResult).toEqual({
87
+ preloadedChildren,
88
+ updatedTree: [createParent('root', preloadedChildren)],
89
+ newTreeItemsRecord: {
90
+ root: { label: 'root' },
91
+ 'child-1': { label: 'child-1' },
92
+ 'child-2': { label: 'child-2' },
93
+ },
94
+ });
95
+ expect(result.current.tree.current).toEqual([createParent('root', preloadedChildren)]);
96
+ expect(result.current.treeItemsRecord.current).toEqual({
97
+ root: { label: 'root' },
98
+ 'child-1': { label: 'child-1' },
99
+ 'child-2': { label: 'child-2' },
100
+ });
101
+ }));
102
+ it('should run search and preload required nodes', () => __awaiter(void 0, void 0, void 0, function* () {
103
+ const searchedTree = [createParent('expandable-root', []), createLeaf('leaf')];
104
+ const onSearch = vi.fn(() => __awaiter(void 0, void 0, void 0, function* () {
105
+ return ({
106
+ tree: searchedTree,
107
+ needPreloadNodes: ['expandable-root', 'external-root'],
108
+ });
109
+ }));
110
+ const onPreloadNodes = vi.fn((nodeIds) => __awaiter(void 0, void 0, void 0, function* () {
111
+ return (Object.assign({ 'expandable-root': [createLeaf('expandable-child')], 'external-root': [createLeaf('external-child')] }, Object.fromEntries(nodeIds.filter(id => id !== 'expandable-root' && id !== 'external-root').map(id => [id, []]))));
112
+ }));
113
+ const { result } = renderHook(() => useSearchableTree({
114
+ initTree: [createParent('init-root', [])],
115
+ onPreloadNode: vi.fn(),
116
+ onPreloadNodes,
117
+ onSearch,
118
+ mapNodeToRecordItem,
119
+ }));
120
+ act(() => {
121
+ result.current.onExpand(['expandable-root']);
122
+ result.current.search.onChange('query');
123
+ });
124
+ yield waitFor(() => {
125
+ expect(onSearch).toHaveBeenCalledWith({ search: 'query' }, expect.any(AbortSignal));
126
+ expect(onPreloadNodes).toHaveBeenCalledWith(['expandable-root', 'external-root'], expect.any(AbortSignal));
127
+ });
128
+ expect(result.current.tree.current).toEqual([
129
+ createParent('expandable-root', [createLeaf('expandable-child')]),
130
+ createLeaf('leaf'),
131
+ ]);
132
+ expect(result.current.treeItemsRecord.current).toEqual({
133
+ 'expandable-root': { label: 'expandable-root' },
134
+ 'expandable-child': { label: 'expandable-child' },
135
+ leaf: { label: 'leaf' },
136
+ });
137
+ }));
138
+ it('should update tree and record from search result without preloading', () => __awaiter(void 0, void 0, void 0, function* () {
139
+ const onSearch = vi.fn(() => __awaiter(void 0, void 0, void 0, function* () {
140
+ return ({
141
+ tree: [createParent('root-node', [createLeaf('child-node')])],
142
+ needPreloadNodes: [],
143
+ });
144
+ }));
145
+ const { result } = renderHook(() => useSearchableTree({
146
+ initTree: [createLeaf('initial')],
147
+ onPreloadNode: vi.fn(),
148
+ onPreloadNodes: vi.fn(),
149
+ onSearch,
150
+ mapNodeToRecordItem,
151
+ }));
152
+ act(() => {
153
+ result.current.search.onChange('query');
154
+ });
155
+ yield waitFor(() => {
156
+ expect(onSearch).toHaveBeenCalledWith({ search: 'query' }, expect.any(AbortSignal));
157
+ expect(result.current.tree.current).toEqual([createParent('root-node', [createLeaf('child-node')])]);
158
+ });
159
+ expect(result.current.expandedNodes.current).toEqual([]);
160
+ expect(result.current.treeItemsRecord.current).toEqual({
161
+ 'root-node': { label: 'root-node' },
162
+ 'child-node': { label: 'child-node' },
163
+ });
164
+ }));
165
+ });
@@ -0,0 +1,130 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { act, renderHook } from '@testing-library/react';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { useTreeMultiSelection } from '../useTreeMultiSelection';
13
+ const createLeaf = (id) => ({
14
+ id,
15
+ title: id,
16
+ });
17
+ const createParent = (id, nested) => ({
18
+ id,
19
+ title: id,
20
+ nested,
21
+ });
22
+ describe('useTreeMultiSelection', () => {
23
+ it('should keep selected state uncontrolled by default', () => __awaiter(void 0, void 0, void 0, function* () {
24
+ const { result } = renderHook(() => useTreeMultiSelection({
25
+ onDataLoad: vi.fn(() => __awaiter(void 0, void 0, void 0, function* () { return ({ preloadedChildren: [], updatedTree: [] }); })),
26
+ onSelect: () => ({ added: ['a'], removed: [] }),
27
+ }));
28
+ expect(result.current.selected).toEqual([]);
29
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
30
+ yield result.current.onSelect(['a'], createLeaf('a'));
31
+ }));
32
+ expect(result.current.selected).toEqual(['a']);
33
+ }));
34
+ it('should call onChangeSelected when selection updates', () => __awaiter(void 0, void 0, void 0, function* () {
35
+ const onChangeSelected = vi.fn();
36
+ const { result } = renderHook(() => useTreeMultiSelection({
37
+ onDataLoad: vi.fn(() => __awaiter(void 0, void 0, void 0, function* () { return ({ preloadedChildren: [], updatedTree: [] }); })),
38
+ onSelect: () => ({ added: ['a'], removed: [] }),
39
+ onChangeSelected,
40
+ }));
41
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
42
+ yield result.current.onSelect(['a'], createLeaf('a'));
43
+ }));
44
+ expect(onChangeSelected).toHaveBeenCalledWith(['a']);
45
+ }));
46
+ it('should treat selected prop as controlled', () => __awaiter(void 0, void 0, void 0, function* () {
47
+ const onChangeSelected = vi.fn();
48
+ const { result, rerender } = renderHook(({ selected }) => useTreeMultiSelection({
49
+ onDataLoad: vi.fn(() => __awaiter(void 0, void 0, void 0, function* () { return ({ preloadedChildren: [], updatedTree: [] }); })),
50
+ onSelect: () => ({ added: ['b'], removed: [] }),
51
+ selected,
52
+ onChangeSelected,
53
+ }), { initialProps: { selected: ['a'] } });
54
+ expect(result.current.selected).toEqual(['a']);
55
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
56
+ yield result.current.onSelect(['a', 'b'], createLeaf('b'));
57
+ }));
58
+ // controlled value does not change until parent updates it
59
+ expect(result.current.selected).toEqual(['a']);
60
+ expect(onChangeSelected).toHaveBeenCalledWith(['a', 'b']);
61
+ rerender({ selected: ['a', 'b'] });
62
+ expect(result.current.selected).toEqual(['a', 'b']);
63
+ }));
64
+ it('should preload children when selecting empty parent node and pass cloned node to onSelect', () => __awaiter(void 0, void 0, void 0, function* () {
65
+ const node = createParent('parent', []);
66
+ const preloadedChildren = [createLeaf('child-1'), createLeaf('child-2')];
67
+ const onDataLoad = vi.fn(() => __awaiter(void 0, void 0, void 0, function* () {
68
+ return ({
69
+ preloadedChildren,
70
+ updatedTree: [createParent('parent', preloadedChildren)],
71
+ });
72
+ }));
73
+ const onSelect = vi.fn(() => ({ added: ['parent', 'child-1', 'child-2'], removed: [] }));
74
+ const { result } = renderHook(() => useTreeMultiSelection({
75
+ onDataLoad,
76
+ onSelect,
77
+ }));
78
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
79
+ yield result.current.onSelect(['parent'], node);
80
+ }));
81
+ expect(onDataLoad).toHaveBeenCalledWith(node);
82
+ expect(onSelect).toHaveBeenCalledWith({
83
+ selectedKeys: ['parent'],
84
+ node: createParent('parent', preloadedChildren),
85
+ isSelected: true,
86
+ });
87
+ expect(result.current.selected.sort()).toEqual(['child-1', 'child-2', 'parent']);
88
+ }));
89
+ it('should not preload when parent node already has children', () => __awaiter(void 0, void 0, void 0, function* () {
90
+ const node = createParent('parent', [createLeaf('child')]);
91
+ const onDataLoad = vi.fn(() => __awaiter(void 0, void 0, void 0, function* () {
92
+ return ({
93
+ preloadedChildren: [],
94
+ updatedTree: [],
95
+ });
96
+ }));
97
+ const onSelect = vi.fn(() => ({ added: ['parent'], removed: [] }));
98
+ const { result } = renderHook(() => useTreeMultiSelection({
99
+ onDataLoad,
100
+ onSelect,
101
+ }));
102
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
103
+ yield result.current.onSelect(['parent'], node);
104
+ }));
105
+ expect(onDataLoad).not.toHaveBeenCalled();
106
+ expect(onSelect).toHaveBeenCalledWith({
107
+ selectedKeys: ['parent'],
108
+ node,
109
+ isSelected: true,
110
+ });
111
+ }));
112
+ it('should add and remove ids from selection without duplicates', () => __awaiter(void 0, void 0, void 0, function* () {
113
+ const { result } = renderHook(() => useTreeMultiSelection({
114
+ onDataLoad: vi.fn(() => __awaiter(void 0, void 0, void 0, function* () { return ({ preloadedChildren: [], updatedTree: [] }); })),
115
+ onSelect: () => ({ added: ['a', 'a', 'b'], removed: [] }),
116
+ }));
117
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
118
+ yield result.current.onSelect(['a'], createLeaf('a'));
119
+ }));
120
+ expect(result.current.selected.sort()).toEqual(['a', 'b']);
121
+ const { result: result2 } = renderHook(() => useTreeMultiSelection({
122
+ onDataLoad: vi.fn(() => __awaiter(void 0, void 0, void 0, function* () { return ({ preloadedChildren: [], updatedTree: [] }); })),
123
+ onSelect: () => ({ added: [], removed: ['a'] }),
124
+ selected: ['a', 'b'],
125
+ }));
126
+ yield act(() => __awaiter(void 0, void 0, void 0, function* () {
127
+ yield result2.current.onSelect(['b'], createLeaf('a'));
128
+ }));
129
+ }));
130
+ });
@@ -0,0 +1,2 @@
1
+ export * from './useSearchableTree';
2
+ export * from './useTreeMultiSelection';
@@ -0,0 +1,2 @@
1
+ export * from './useSearchableTree';
2
+ export * from './useTreeMultiSelection';
@@ -0,0 +1,28 @@
1
+ import type { SearchableTreeDataLoadResult, TreeNodeProps } from '../types';
2
+ export type SearchResult<TTreeNode extends TreeNodeProps> = {
3
+ tree: TTreeNode[];
4
+ needPreloadNodes: string[];
5
+ };
6
+ export type SearchParams = {
7
+ search: string;
8
+ };
9
+ type UseSearchableTreeParams<TRecordValue, TTreeNode extends TreeNodeProps> = {
10
+ initTree: TTreeNode[];
11
+ onPreloadNode: (node: TreeNodeProps) => Promise<TTreeNode[]>;
12
+ onPreloadNodes: (nodes: string[], signal?: AbortSignal) => Promise<Record<string, TTreeNode[]>>;
13
+ onSearch: (params: SearchParams, signal?: AbortSignal) => Promise<SearchResult<TTreeNode>>;
14
+ mapNodeToRecordItem: (node: TTreeNode) => TRecordValue;
15
+ };
16
+ export declare function useSearchableTree<TRecordValue, TTreeNode extends TreeNodeProps>({ initTree, onPreloadNode, onPreloadNodes, onSearch, mapNodeToRecordItem, }: UseSearchableTreeParams<TRecordValue, TTreeNode>): {
17
+ tree: import("@siberiacancode/reactuse").StateRef<TTreeNode[]>;
18
+ expandedNodes: import("@siberiacancode/reactuse").StateRef<string[]>;
19
+ loading: boolean;
20
+ treeItemsRecord: import("@siberiacancode/reactuse").StateRef<Record<string, TRecordValue>>;
21
+ search: {
22
+ value: string;
23
+ onChange: import("react").Dispatch<import("react").SetStateAction<string>>;
24
+ };
25
+ onExpand: (nodes: string[]) => void;
26
+ onDataLoad: (node: TreeNodeProps) => Promise<SearchableTreeDataLoadResult<TTreeNode, TRecordValue>>;
27
+ };
28
+ export {};
@@ -0,0 +1,108 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { useDebounceValue, useDidUpdate, useRefState } from '@siberiacancode/reactuse';
11
+ import { cancelable } from 'cancelable-promise';
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { collectEmptyNestedNodesInExpanded, setChildrenOfTreeNode, traverse } from '../helpers';
14
+ export function useSearchableTree({ initTree, onPreloadNode, onPreloadNodes, onSearch, mapNodeToRecordItem, }) {
15
+ const tree = useRefState(initTree);
16
+ const treeItemsRecord = useRefState({});
17
+ const expandedNodes = useRefState([]);
18
+ const [search, setSearch] = useState('');
19
+ const debouncedSearch = useDebounceValue(search, 500);
20
+ const [loading, setLoading] = useState(false);
21
+ const searchPromiseRef = useRef(null);
22
+ const searchAbortControllerRef = useRef(null);
23
+ const buildTreeItemsRecord = useCallback((nodes) => {
24
+ const record = {};
25
+ traverse(nodes, node => {
26
+ record[node.id] = mapNodeToRecordItem(node);
27
+ });
28
+ return record;
29
+ }, [mapNodeToRecordItem]);
30
+ const onExpand = useCallback((nodes) => {
31
+ expandedNodes.current = nodes;
32
+ }, [expandedNodes]);
33
+ const onDataLoad = useCallback((node) => __awaiter(this, void 0, void 0, function* () {
34
+ const preloadedChildren = yield onPreloadNode(node);
35
+ const updatedTree = setChildrenOfTreeNode(tree.current, node.id, preloadedChildren);
36
+ tree.current = updatedTree;
37
+ const newTreeItemsRecord = Object.assign({}, treeItemsRecord.current);
38
+ traverse(preloadedChildren, child => {
39
+ newTreeItemsRecord[child.id] = mapNodeToRecordItem(child);
40
+ });
41
+ treeItemsRecord.current = newTreeItemsRecord;
42
+ return { preloadedChildren, updatedTree, newTreeItemsRecord };
43
+ }), [mapNodeToRecordItem, onPreloadNode, tree, treeItemsRecord]);
44
+ const handleSearch = useCallback((searchQuery) => __awaiter(this, void 0, void 0, function* () {
45
+ var _a, _b;
46
+ (_a = searchPromiseRef.current) === null || _a === void 0 ? void 0 : _a.cancel();
47
+ (_b = searchAbortControllerRef.current) === null || _b === void 0 ? void 0 : _b.abort();
48
+ setLoading(true);
49
+ const abortController = new AbortController();
50
+ searchAbortControllerRef.current = abortController;
51
+ const searchPromise = cancelable(onSearch({ search: searchQuery }, abortController.signal));
52
+ searchPromiseRef.current = searchPromise;
53
+ try {
54
+ const { tree: searchedTree, needPreloadNodes } = yield searchPromise;
55
+ if (!searchPromise.isCanceled()) {
56
+ tree.current = searchedTree;
57
+ treeItemsRecord.current = buildTreeItemsRecord(searchedTree);
58
+ const expandedSet = new Set(expandedNodes.current);
59
+ const toPreloadExpandableNodes = collectEmptyNestedNodesInExpanded(searchedTree, expandedSet);
60
+ const collectedNodesForPreload = Array.from(new Set([...toPreloadExpandableNodes.map(node => node.id), ...needPreloadNodes]));
61
+ if (!collectedNodesForPreload.length) {
62
+ return;
63
+ }
64
+ const preloadedNodes = yield onPreloadNodes(collectedNodesForPreload, abortController.signal);
65
+ if (searchPromiseRef.current !== searchPromise) {
66
+ return;
67
+ }
68
+ let tmpTree = [...searchedTree];
69
+ for (const [nodeId, children] of Object.entries(preloadedNodes)) {
70
+ tmpTree = setChildrenOfTreeNode(tmpTree, nodeId, children);
71
+ }
72
+ tree.current = tmpTree;
73
+ treeItemsRecord.current = buildTreeItemsRecord(tmpTree);
74
+ }
75
+ }
76
+ finally {
77
+ if (searchPromiseRef.current === searchPromise) {
78
+ setLoading(false);
79
+ searchPromiseRef.current = null;
80
+ searchAbortControllerRef.current = null;
81
+ }
82
+ }
83
+ }), [buildTreeItemsRecord, expandedNodes, onPreloadNodes, onSearch, tree, treeItemsRecord]);
84
+ useDidUpdate(() => {
85
+ handleSearch(debouncedSearch);
86
+ }, [debouncedSearch, handleSearch]);
87
+ useEffect(() => {
88
+ tree.current = initTree;
89
+ treeItemsRecord.current = buildTreeItemsRecord(initTree);
90
+ }, [buildTreeItemsRecord, initTree, tree, treeItemsRecord]);
91
+ useEffect(() => () => {
92
+ var _a, _b;
93
+ (_a = searchPromiseRef.current) === null || _a === void 0 ? void 0 : _a.cancel();
94
+ (_b = searchAbortControllerRef.current) === null || _b === void 0 ? void 0 : _b.abort();
95
+ }, []);
96
+ return {
97
+ tree,
98
+ expandedNodes,
99
+ loading,
100
+ treeItemsRecord,
101
+ search: {
102
+ value: search,
103
+ onChange: setSearch,
104
+ },
105
+ onExpand,
106
+ onDataLoad,
107
+ };
108
+ }
@@ -0,0 +1,12 @@
1
+ import { PreloadNodeHandler, SelectHandler, TreeNodeProps } from '../types';
2
+ type UseTreeMultiSelectionParams<TTreeNode extends TreeNodeProps> = {
3
+ onDataLoad: PreloadNodeHandler<TTreeNode>;
4
+ onSelect: SelectHandler;
5
+ selected?: string[];
6
+ onChangeSelected?: (newSelected: string[]) => void;
7
+ };
8
+ export declare function useTreeMultiSelection<TTreeNode extends TreeNodeProps>({ onDataLoad, onSelect: onSelectProp, selected, onChangeSelected, }: UseTreeMultiSelectionParams<TTreeNode>): {
9
+ selected: string[];
10
+ onSelect: (selectedKeys: string[], node: TreeNodeProps) => Promise<void>;
11
+ };
12
+ export {};
@@ -0,0 +1,43 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { useCallback } from 'react';
11
+ import { useValueControl } from '@snack-uikit/utils';
12
+ const getNewSelectedIds = (selectedIds, added, removed) => {
13
+ const result = new Set(selectedIds);
14
+ added.forEach(id => {
15
+ result.add(id);
16
+ });
17
+ removed.forEach(id => {
18
+ result.delete(id);
19
+ });
20
+ return Array.from(result);
21
+ };
22
+ export function useTreeMultiSelection({ onDataLoad, onSelect: onSelectProp, selected, onChangeSelected, }) {
23
+ const [selectedIds = [], setSelectedIds] = useValueControl({
24
+ value: selected,
25
+ defaultValue: [],
26
+ onChange: onChangeSelected,
27
+ });
28
+ const onSelect = useCallback((selectedKeys, node) => __awaiter(this, void 0, void 0, function* () {
29
+ const isSelected = selectedKeys.includes(node.id);
30
+ const clonedNode = structuredClone(node);
31
+ if (node.nested && !node.nested.length) {
32
+ const { preloadedChildren } = yield onDataLoad(node);
33
+ clonedNode.nested = preloadedChildren;
34
+ }
35
+ const { added, removed } = onSelectProp({ selectedKeys, node: clonedNode, isSelected });
36
+ const updatedSelectedIds = getNewSelectedIds(selectedIds, added, removed);
37
+ setSelectedIds(updatedSelectedIds);
38
+ }), [onDataLoad, onSelectProp, selectedIds, setSelectedIds]);
39
+ return {
40
+ selected: selectedIds,
41
+ onSelect,
42
+ };
43
+ }
@@ -103,3 +103,20 @@ export type TreeBaseProps = TreeView | TreeMultiSelect | TreeSingleSelect;
103
103
  export type ExtendedTreeNodeProps = TreeNodeProps & {
104
104
  getTitle?(): void;
105
105
  };
106
+ export type PreloadNodeHandler<TTreeNode extends TreeNodeProps> = (node: TreeNodeProps) => Promise<{
107
+ preloadedChildren: TTreeNode[];
108
+ updatedTree: TTreeNode[];
109
+ }>;
110
+ export type SearchableTreeDataLoadResult<TTreeNode extends TreeNodeProps, TRecordValue> = {
111
+ preloadedChildren: TTreeNode[];
112
+ updatedTree: TTreeNode[];
113
+ newTreeItemsRecord: Record<string, TRecordValue>;
114
+ };
115
+ export type SelectHandler = (props: {
116
+ selectedKeys: string[];
117
+ node: TreeNodeProps;
118
+ isSelected: boolean;
119
+ }) => {
120
+ added: string[];
121
+ removed: string[];
122
+ };