@snack-uikit/tree 0.10.3 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +121 -0
- package/dist/cjs/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.d.ts +1 -0
- package/dist/cjs/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.js +80 -0
- package/dist/cjs/helpers/__tests__/setChildrenOfTreeNode.spec.d.ts +1 -0
- package/dist/cjs/helpers/__tests__/setChildrenOfTreeNode.spec.js +102 -0
- package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.d.ts +2 -0
- package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.js +17 -0
- package/dist/cjs/helpers/index.d.ts +2 -0
- package/dist/cjs/helpers/index.js +3 -1
- package/dist/cjs/helpers/setChildrenOfTreeNode.d.ts +2 -0
- package/dist/cjs/helpers/setChildrenOfTreeNode.js +28 -0
- package/dist/cjs/helpers/sortTreeItemsByTitle.js +2 -2
- package/dist/cjs/helpers/traverse.d.ts +7 -1
- package/dist/cjs/helpers/traverse.js +38 -2
- package/dist/cjs/hooks/__tests__/useSearchableTree.spec.d.ts +1 -0
- package/dist/cjs/hooks/__tests__/useSearchableTree.spec.js +230 -0
- package/dist/cjs/hooks/__tests__/useTreeMultiSelection.spec.d.ts +1 -0
- package/dist/cjs/hooks/__tests__/useTreeMultiSelection.spec.js +225 -0
- package/dist/cjs/hooks/index.d.ts +2 -0
- package/dist/cjs/hooks/index.js +26 -0
- package/dist/cjs/hooks/useSearchableTree.d.ts +28 -0
- package/dist/cjs/hooks/useSearchableTree.js +147 -0
- package/dist/cjs/hooks/useTreeMultiSelection.d.ts +12 -0
- package/dist/cjs/hooks/useTreeMultiSelection.js +82 -0
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/types.d.ts +17 -0
- package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.d.ts +1 -0
- package/dist/esm/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.js +64 -0
- package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.d.ts +1 -0
- package/dist/esm/helpers/__tests__/setChildrenOfTreeNode.spec.js +50 -0
- package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.d.ts +2 -0
- package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.js +11 -0
- package/dist/esm/helpers/index.d.ts +2 -0
- package/dist/esm/helpers/index.js +2 -0
- package/dist/esm/helpers/setChildrenOfTreeNode.d.ts +2 -0
- package/dist/esm/helpers/setChildrenOfTreeNode.js +20 -0
- package/dist/esm/helpers/sortTreeItemsByTitle.js +2 -2
- package/dist/esm/helpers/traverse.d.ts +7 -1
- package/dist/esm/helpers/traverse.js +24 -0
- package/dist/esm/hooks/__tests__/useSearchableTree.spec.d.ts +1 -0
- package/dist/esm/hooks/__tests__/useSearchableTree.spec.js +165 -0
- package/dist/esm/hooks/__tests__/useTreeMultiSelection.spec.d.ts +1 -0
- package/dist/esm/hooks/__tests__/useTreeMultiSelection.spec.js +130 -0
- package/dist/esm/hooks/index.d.ts +2 -0
- package/dist/esm/hooks/index.js +2 -0
- package/dist/esm/hooks/useSearchableTree.d.ts +28 -0
- package/dist/esm/hooks/useSearchableTree.js +108 -0
- package/dist/esm/hooks/useTreeMultiSelection.d.ts +12 -0
- package/dist/esm/hooks/useTreeMultiSelection.js +43 -0
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/types.d.ts +17 -0
- package/package.json +22 -4
- package/src/helpers/__tests__/collectEmptyNestedNodesInExpanded.spec.ts +88 -0
- package/src/helpers/__tests__/setChildrenOfTreeNode.spec.ts +68 -0
- package/src/helpers/collectEmptyNestedNodesInExpanded.ts +16 -0
- package/src/helpers/index.ts +2 -0
- package/src/helpers/setChildrenOfTreeNode.ts +30 -0
- package/src/helpers/sortTreeItemsByTitle.ts +2 -2
- package/src/helpers/traverse.ts +38 -1
- package/src/hooks/__tests__/useSearchableTree.spec.ts +200 -0
- package/src/hooks/__tests__/useTreeMultiSelection.spec.ts +165 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useSearchableTree.ts +163 -0
- package/src/hooks/useTreeMultiSelection.ts +61 -0
- package/src/index.ts +2 -1
- package/src/types.ts +15 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { type DependencyList, useEffect, useRef } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import type { SearchableTreeDataLoadResult, TreeNodeProps } from '../../types';
|
|
6
|
+
import { SearchResult, useSearchableTree } from '../useSearchableTree';
|
|
7
|
+
|
|
8
|
+
vi.mock('@siberiacancode/reactuse', () => ({
|
|
9
|
+
useDebounceValue: (value: string) => value,
|
|
10
|
+
useDidUpdate: (effect: () => void, deps: DependencyList) => {
|
|
11
|
+
const isFirstRenderRef = useRef(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (isFirstRenderRef.current) {
|
|
15
|
+
isFirstRenderRef.current = false;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
effect();
|
|
20
|
+
}, [deps, effect]);
|
|
21
|
+
},
|
|
22
|
+
useRefState: <TValue>(initialValue: TValue) => useRef<TValue>(initialValue),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
type TreeRecord = {
|
|
26
|
+
label: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createLeaf = (id: string): TreeNodeProps => ({
|
|
30
|
+
id,
|
|
31
|
+
title: id,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const createParent = (id: string, nested: TreeNodeProps[]): TreeNodeProps => ({
|
|
35
|
+
id,
|
|
36
|
+
title: id,
|
|
37
|
+
nested,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const mapNodeToRecordItem = (node: TreeNodeProps): TreeRecord => ({
|
|
41
|
+
label: String(node.title),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('useSearchableTree', () => {
|
|
45
|
+
it('should initialize tree and treeItemsRecord from initTree', () => {
|
|
46
|
+
const initTree = [createLeaf('node-1'), createParent('node-2', [createLeaf('node-2-1')])];
|
|
47
|
+
|
|
48
|
+
const { result } = renderHook(() =>
|
|
49
|
+
useSearchableTree<TreeRecord, TreeNodeProps>({
|
|
50
|
+
initTree,
|
|
51
|
+
onPreloadNode: vi.fn(),
|
|
52
|
+
onPreloadNodes: vi.fn(),
|
|
53
|
+
onSearch: vi.fn(),
|
|
54
|
+
mapNodeToRecordItem,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(result.current.tree.current).toEqual(initTree);
|
|
59
|
+
expect(result.current.treeItemsRecord.current).toEqual({
|
|
60
|
+
'node-1': { label: 'node-1' },
|
|
61
|
+
'node-2': { label: 'node-2' },
|
|
62
|
+
'node-2-1': { label: 'node-2-1' },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should update expandedNodes on onExpand call', () => {
|
|
67
|
+
const { result } = renderHook(() =>
|
|
68
|
+
useSearchableTree<TreeRecord, TreeNodeProps>({
|
|
69
|
+
initTree: [createLeaf('root')],
|
|
70
|
+
onPreloadNode: vi.fn(),
|
|
71
|
+
onPreloadNodes: vi.fn(),
|
|
72
|
+
onSearch: vi.fn(),
|
|
73
|
+
mapNodeToRecordItem,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
act(() => {
|
|
78
|
+
result.current.onExpand(['root', 'child']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.current.expandedNodes.current).toEqual(['root', 'child']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should preload node children and update tree and record on onDataLoad', async () => {
|
|
85
|
+
const initTree = [createParent('root', [])];
|
|
86
|
+
const preloadedChildren = [createLeaf('child-1'), createLeaf('child-2')];
|
|
87
|
+
const onPreloadNode = vi.fn().mockResolvedValue(preloadedChildren);
|
|
88
|
+
|
|
89
|
+
const { result } = renderHook(() =>
|
|
90
|
+
useSearchableTree<TreeRecord, TreeNodeProps>({
|
|
91
|
+
initTree,
|
|
92
|
+
onPreloadNode,
|
|
93
|
+
onPreloadNodes: vi.fn(),
|
|
94
|
+
onSearch: vi.fn(),
|
|
95
|
+
mapNodeToRecordItem,
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let loadResult: SearchableTreeDataLoadResult<TreeNodeProps, TreeRecord> | undefined;
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
loadResult = await result.current.onDataLoad(createParent('root', []));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(onPreloadNode).toHaveBeenCalledWith(createParent('root', []));
|
|
106
|
+
expect(loadResult).toEqual({
|
|
107
|
+
preloadedChildren,
|
|
108
|
+
updatedTree: [createParent('root', preloadedChildren)],
|
|
109
|
+
newTreeItemsRecord: {
|
|
110
|
+
root: { label: 'root' },
|
|
111
|
+
'child-1': { label: 'child-1' },
|
|
112
|
+
'child-2': { label: 'child-2' },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
expect(result.current.tree.current).toEqual([createParent('root', preloadedChildren)]);
|
|
116
|
+
expect(result.current.treeItemsRecord.current).toEqual({
|
|
117
|
+
root: { label: 'root' },
|
|
118
|
+
'child-1': { label: 'child-1' },
|
|
119
|
+
'child-2': { label: 'child-2' },
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should run search and preload required nodes', async () => {
|
|
124
|
+
const searchedTree = [createParent('expandable-root', []), createLeaf('leaf')];
|
|
125
|
+
const onSearch = vi.fn(
|
|
126
|
+
async (): Promise<SearchResult<TreeNodeProps>> => ({
|
|
127
|
+
tree: searchedTree,
|
|
128
|
+
needPreloadNodes: ['expandable-root', 'external-root'],
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const onPreloadNodes = vi.fn(async (nodeIds: string[]) => ({
|
|
132
|
+
'expandable-root': [createLeaf('expandable-child')],
|
|
133
|
+
'external-root': [createLeaf('external-child')],
|
|
134
|
+
...Object.fromEntries(
|
|
135
|
+
nodeIds.filter(id => id !== 'expandable-root' && id !== 'external-root').map(id => [id, []]),
|
|
136
|
+
),
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const { result } = renderHook(() =>
|
|
140
|
+
useSearchableTree<TreeRecord, TreeNodeProps>({
|
|
141
|
+
initTree: [createParent('init-root', [])],
|
|
142
|
+
onPreloadNode: vi.fn(),
|
|
143
|
+
onPreloadNodes,
|
|
144
|
+
onSearch,
|
|
145
|
+
mapNodeToRecordItem,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
act(() => {
|
|
150
|
+
result.current.onExpand(['expandable-root']);
|
|
151
|
+
result.current.search.onChange('query');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(onSearch).toHaveBeenCalledWith({ search: 'query' }, expect.any(AbortSignal));
|
|
156
|
+
expect(onPreloadNodes).toHaveBeenCalledWith(['expandable-root', 'external-root'], expect.any(AbortSignal));
|
|
157
|
+
});
|
|
158
|
+
expect(result.current.tree.current).toEqual([
|
|
159
|
+
createParent('expandable-root', [createLeaf('expandable-child')]),
|
|
160
|
+
createLeaf('leaf'),
|
|
161
|
+
]);
|
|
162
|
+
expect(result.current.treeItemsRecord.current).toEqual({
|
|
163
|
+
'expandable-root': { label: 'expandable-root' },
|
|
164
|
+
'expandable-child': { label: 'expandable-child' },
|
|
165
|
+
leaf: { label: 'leaf' },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should update tree and record from search result without preloading', async () => {
|
|
170
|
+
const onSearch = vi.fn(async () => ({
|
|
171
|
+
tree: [createParent('root-node', [createLeaf('child-node')])],
|
|
172
|
+
needPreloadNodes: [],
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const { result } = renderHook(() =>
|
|
176
|
+
useSearchableTree<TreeRecord, TreeNodeProps>({
|
|
177
|
+
initTree: [createLeaf('initial')],
|
|
178
|
+
onPreloadNode: vi.fn(),
|
|
179
|
+
onPreloadNodes: vi.fn(),
|
|
180
|
+
onSearch,
|
|
181
|
+
mapNodeToRecordItem,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
result.current.search.onChange('query');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await waitFor(() => {
|
|
190
|
+
expect(onSearch).toHaveBeenCalledWith({ search: 'query' }, expect.any(AbortSignal));
|
|
191
|
+
expect(result.current.tree.current).toEqual([createParent('root-node', [createLeaf('child-node')])]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.current.expandedNodes.current).toEqual([]);
|
|
195
|
+
expect(result.current.treeItemsRecord.current).toEqual({
|
|
196
|
+
'root-node': { label: 'root-node' },
|
|
197
|
+
'child-node': { label: 'child-node' },
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { ParentTreeNode, TreeNodeProps } from '../../types';
|
|
5
|
+
import { useTreeMultiSelection } from '../useTreeMultiSelection';
|
|
6
|
+
|
|
7
|
+
const createLeaf = (id: string): TreeNodeProps => ({
|
|
8
|
+
id,
|
|
9
|
+
title: id,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const createParent = (id: string, nested: TreeNodeProps[]): ParentTreeNode => ({
|
|
13
|
+
id,
|
|
14
|
+
title: id,
|
|
15
|
+
nested,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('useTreeMultiSelection', () => {
|
|
19
|
+
it('should keep selected state uncontrolled by default', async () => {
|
|
20
|
+
const { result } = renderHook(() =>
|
|
21
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
22
|
+
onDataLoad: vi.fn(async () => ({ preloadedChildren: [], updatedTree: [] })),
|
|
23
|
+
onSelect: () => ({ added: ['a'], removed: [] }),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(result.current.selected).toEqual([]);
|
|
28
|
+
|
|
29
|
+
await act(async () => {
|
|
30
|
+
await result.current.onSelect(['a'], createLeaf('a'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.current.selected).toEqual(['a']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should call onChangeSelected when selection updates', async () => {
|
|
37
|
+
const onChangeSelected = vi.fn();
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() =>
|
|
40
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
41
|
+
onDataLoad: vi.fn(async () => ({ preloadedChildren: [], updatedTree: [] })),
|
|
42
|
+
onSelect: () => ({ added: ['a'], removed: [] }),
|
|
43
|
+
onChangeSelected,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
await act(async () => {
|
|
48
|
+
await result.current.onSelect(['a'], createLeaf('a'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(onChangeSelected).toHaveBeenCalledWith(['a']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should treat selected prop as controlled', async () => {
|
|
55
|
+
const onChangeSelected = vi.fn();
|
|
56
|
+
|
|
57
|
+
const { result, rerender } = renderHook(
|
|
58
|
+
({ selected }: { selected: string[] }) =>
|
|
59
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
60
|
+
onDataLoad: vi.fn(async () => ({ preloadedChildren: [], updatedTree: [] })),
|
|
61
|
+
onSelect: () => ({ added: ['b'], removed: [] }),
|
|
62
|
+
selected,
|
|
63
|
+
onChangeSelected,
|
|
64
|
+
}),
|
|
65
|
+
{ initialProps: { selected: ['a'] } },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result.current.selected).toEqual(['a']);
|
|
69
|
+
|
|
70
|
+
await act(async () => {
|
|
71
|
+
await result.current.onSelect(['a', 'b'], createLeaf('b'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// controlled value does not change until parent updates it
|
|
75
|
+
expect(result.current.selected).toEqual(['a']);
|
|
76
|
+
expect(onChangeSelected).toHaveBeenCalledWith(['a', 'b']);
|
|
77
|
+
|
|
78
|
+
rerender({ selected: ['a', 'b'] });
|
|
79
|
+
expect(result.current.selected).toEqual(['a', 'b']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should preload children when selecting empty parent node and pass cloned node to onSelect', async () => {
|
|
83
|
+
const node = createParent('parent', []);
|
|
84
|
+
const preloadedChildren = [createLeaf('child-1'), createLeaf('child-2')];
|
|
85
|
+
|
|
86
|
+
const onDataLoad = vi.fn(async () => ({
|
|
87
|
+
preloadedChildren,
|
|
88
|
+
updatedTree: [createParent('parent', preloadedChildren)],
|
|
89
|
+
}));
|
|
90
|
+
const onSelect = vi.fn(() => ({ added: ['parent', 'child-1', 'child-2'], removed: [] }));
|
|
91
|
+
|
|
92
|
+
const { result } = renderHook(() =>
|
|
93
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
94
|
+
onDataLoad,
|
|
95
|
+
onSelect,
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await result.current.onSelect(['parent'], node);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(onDataLoad).toHaveBeenCalledWith(node);
|
|
104
|
+
expect(onSelect).toHaveBeenCalledWith({
|
|
105
|
+
selectedKeys: ['parent'],
|
|
106
|
+
node: createParent('parent', preloadedChildren),
|
|
107
|
+
isSelected: true,
|
|
108
|
+
});
|
|
109
|
+
expect(result.current.selected.sort()).toEqual(['child-1', 'child-2', 'parent']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should not preload when parent node already has children', async () => {
|
|
113
|
+
const node = createParent('parent', [createLeaf('child')]);
|
|
114
|
+
const onDataLoad = vi.fn(async () => ({
|
|
115
|
+
preloadedChildren: [],
|
|
116
|
+
updatedTree: [],
|
|
117
|
+
}));
|
|
118
|
+
const onSelect = vi.fn(() => ({ added: ['parent'], removed: [] }));
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
122
|
+
onDataLoad,
|
|
123
|
+
onSelect,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
await result.current.onSelect(['parent'], node);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(onDataLoad).not.toHaveBeenCalled();
|
|
132
|
+
expect(onSelect).toHaveBeenCalledWith({
|
|
133
|
+
selectedKeys: ['parent'],
|
|
134
|
+
node,
|
|
135
|
+
isSelected: true,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should add and remove ids from selection without duplicates', async () => {
|
|
140
|
+
const { result } = renderHook(() =>
|
|
141
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
142
|
+
onDataLoad: vi.fn(async () => ({ preloadedChildren: [], updatedTree: [] })),
|
|
143
|
+
onSelect: () => ({ added: ['a', 'a', 'b'], removed: [] }),
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
await result.current.onSelect(['a'], createLeaf('a'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.current.selected.sort()).toEqual(['a', 'b']);
|
|
152
|
+
|
|
153
|
+
const { result: result2 } = renderHook(() =>
|
|
154
|
+
useTreeMultiSelection<TreeNodeProps>({
|
|
155
|
+
onDataLoad: vi.fn(async () => ({ preloadedChildren: [], updatedTree: [] })),
|
|
156
|
+
onSelect: () => ({ added: [], removed: ['a'] }),
|
|
157
|
+
selected: ['a', 'b'],
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await act(async () => {
|
|
162
|
+
await result2.current.onSelect(['b'], createLeaf('a'));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
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 type { SearchableTreeDataLoadResult, 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
|
+
};
|
|
16
|
+
|
|
17
|
+
type UseSearchableTreeParams<TRecordValue, TTreeNode extends TreeNodeProps> = {
|
|
18
|
+
initTree: TTreeNode[];
|
|
19
|
+
onPreloadNode: (node: TreeNodeProps) => Promise<TTreeNode[]>;
|
|
20
|
+
onPreloadNodes: (nodes: string[], signal?: AbortSignal) => Promise<Record<string, TTreeNode[]>>;
|
|
21
|
+
onSearch: (params: SearchParams, signal?: AbortSignal) => Promise<SearchResult<TTreeNode>>;
|
|
22
|
+
mapNodeToRecordItem: (node: TTreeNode) => TRecordValue;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function useSearchableTree<TRecordValue, TTreeNode extends TreeNodeProps>({
|
|
26
|
+
initTree,
|
|
27
|
+
onPreloadNode,
|
|
28
|
+
onPreloadNodes,
|
|
29
|
+
onSearch,
|
|
30
|
+
mapNodeToRecordItem,
|
|
31
|
+
}: UseSearchableTreeParams<TRecordValue, TTreeNode>) {
|
|
32
|
+
const tree = useRefState<TTreeNode[]>(initTree);
|
|
33
|
+
const treeItemsRecord = useRefState<Record<string, TRecordValue>>({});
|
|
34
|
+
|
|
35
|
+
const expandedNodes = useRefState<string[]>([]);
|
|
36
|
+
|
|
37
|
+
const [search, setSearch] = useState<string>('');
|
|
38
|
+
const debouncedSearch = useDebounceValue(search, 500);
|
|
39
|
+
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const searchPromiseRef = useRef<CancelablePromise<SearchResult<TTreeNode>> | null>(null);
|
|
42
|
+
const searchAbortControllerRef = useRef<AbortController | 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): Promise<SearchableTreeDataLoadResult<TTreeNode, TRecordValue>> => {
|
|
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
|
+
searchAbortControllerRef.current?.abort();
|
|
85
|
+
|
|
86
|
+
setLoading(true);
|
|
87
|
+
const abortController = new AbortController();
|
|
88
|
+
searchAbortControllerRef.current = abortController;
|
|
89
|
+
|
|
90
|
+
const searchPromise = cancelable(onSearch({ search: searchQuery }, abortController.signal));
|
|
91
|
+
searchPromiseRef.current = searchPromise;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const { tree: searchedTree, needPreloadNodes } = await searchPromise;
|
|
95
|
+
if (!searchPromise.isCanceled()) {
|
|
96
|
+
tree.current = searchedTree;
|
|
97
|
+
treeItemsRecord.current = buildTreeItemsRecord(searchedTree);
|
|
98
|
+
|
|
99
|
+
const expandedSet = new Set(expandedNodes.current);
|
|
100
|
+
const toPreloadExpandableNodes = collectEmptyNestedNodesInExpanded(searchedTree, expandedSet);
|
|
101
|
+
const collectedNodesForPreload = Array.from(
|
|
102
|
+
new Set([...toPreloadExpandableNodes.map(node => node.id), ...needPreloadNodes]),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!collectedNodesForPreload.length) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const preloadedNodes = await onPreloadNodes(collectedNodesForPreload, abortController.signal);
|
|
110
|
+
|
|
111
|
+
if (searchPromiseRef.current !== searchPromise) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let tmpTree = [...searchedTree];
|
|
116
|
+
for (const [nodeId, children] of Object.entries(preloadedNodes)) {
|
|
117
|
+
tmpTree = setChildrenOfTreeNode(tmpTree, nodeId, children);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
tree.current = tmpTree;
|
|
121
|
+
treeItemsRecord.current = buildTreeItemsRecord(tmpTree);
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
if (searchPromiseRef.current === searchPromise) {
|
|
125
|
+
setLoading(false);
|
|
126
|
+
searchPromiseRef.current = null;
|
|
127
|
+
searchAbortControllerRef.current = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
[buildTreeItemsRecord, expandedNodes, onPreloadNodes, onSearch, tree, treeItemsRecord],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
useDidUpdate(() => {
|
|
135
|
+
handleSearch(debouncedSearch);
|
|
136
|
+
}, [debouncedSearch, handleSearch]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
tree.current = initTree;
|
|
140
|
+
treeItemsRecord.current = buildTreeItemsRecord(initTree);
|
|
141
|
+
}, [buildTreeItemsRecord, initTree, tree, treeItemsRecord]);
|
|
142
|
+
|
|
143
|
+
useEffect(
|
|
144
|
+
() => () => {
|
|
145
|
+
searchPromiseRef.current?.cancel();
|
|
146
|
+
searchAbortControllerRef.current?.abort();
|
|
147
|
+
},
|
|
148
|
+
[],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
tree,
|
|
153
|
+
expandedNodes,
|
|
154
|
+
loading,
|
|
155
|
+
treeItemsRecord,
|
|
156
|
+
search: {
|
|
157
|
+
value: search,
|
|
158
|
+
onChange: setSearch,
|
|
159
|
+
},
|
|
160
|
+
onExpand,
|
|
161
|
+
onDataLoad,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export * from './components';
|
|
2
|
-
export type { OnNodeClick, TreeNodeId, TreeNodeProps } from './types';
|
|
2
|
+
export type { OnNodeClick, TreeNodeId, TreeNodeProps, ExtendedTreeNodeProps } from './types';
|
|
3
3
|
export { setNonce } from '@snack-uikit/list';
|
|
4
|
+
export * from './helpers';
|
package/src/types.ts
CHANGED
|
@@ -118,3 +118,18 @@ 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 SearchableTreeDataLoadResult<TTreeNode extends TreeNodeProps, TRecordValue> = {
|
|
127
|
+
preloadedChildren: TTreeNode[];
|
|
128
|
+
updatedTree: TTreeNode[];
|
|
129
|
+
newTreeItemsRecord: Record<string, TRecordValue>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type SelectHandler = (props: { selectedKeys: string[]; node: TreeNodeProps; isSelected: boolean }) => {
|
|
133
|
+
added: string[];
|
|
134
|
+
removed: string[];
|
|
135
|
+
};
|