@headless-tree/core 0.0.9 → 0.0.11
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 +12 -0
- package/lib/cjs/core/build-proxified-instance.d.ts +2 -0
- package/lib/cjs/core/build-proxified-instance.js +58 -0
- package/lib/cjs/core/build-static-instance.d.ts +2 -0
- package/lib/cjs/core/build-static-instance.js +27 -0
- package/lib/cjs/core/create-tree.js +55 -36
- package/lib/cjs/features/async-data-loader/feature.js +37 -23
- package/lib/cjs/features/async-data-loader/types.d.ts +2 -1
- package/lib/cjs/features/drag-and-drop/feature.js +64 -32
- package/lib/cjs/features/drag-and-drop/types.d.ts +13 -4
- package/lib/cjs/features/drag-and-drop/utils.d.ts +1 -2
- package/lib/cjs/features/drag-and-drop/utils.js +140 -37
- package/lib/cjs/features/expand-all/feature.js +12 -6
- package/lib/cjs/features/main/types.d.ts +8 -2
- package/lib/cjs/features/renaming/feature.js +33 -18
- package/lib/cjs/features/renaming/types.d.ts +1 -1
- package/lib/cjs/features/search/feature.js +38 -24
- package/lib/cjs/features/search/types.d.ts +0 -1
- package/lib/cjs/features/selection/feature.js +23 -14
- package/lib/cjs/features/sync-data-loader/feature.js +7 -2
- package/lib/cjs/features/tree/feature.d.ts +2 -1
- package/lib/cjs/features/tree/feature.js +85 -63
- package/lib/cjs/features/tree/types.d.ts +5 -3
- package/lib/cjs/index.d.ts +3 -1
- package/lib/cjs/index.js +2 -1
- package/lib/cjs/test-utils/test-tree-do.d.ts +23 -0
- package/lib/cjs/test-utils/test-tree-do.js +99 -0
- package/lib/cjs/test-utils/test-tree-expect.d.ts +15 -0
- package/lib/cjs/test-utils/test-tree-expect.js +62 -0
- package/lib/cjs/test-utils/test-tree.d.ts +47 -0
- package/lib/cjs/test-utils/test-tree.js +195 -0
- package/lib/cjs/types/core.d.ts +31 -15
- package/lib/cjs/utilities/errors.d.ts +1 -0
- package/lib/cjs/utilities/errors.js +5 -0
- package/lib/cjs/utilities/insert-items-at-target.js +10 -3
- package/lib/cjs/utilities/remove-items-from-parents.js +14 -8
- package/lib/cjs/utils.d.ts +3 -3
- package/lib/cjs/utils.js +6 -6
- package/lib/esm/core/build-proxified-instance.d.ts +2 -0
- package/lib/esm/core/build-proxified-instance.js +54 -0
- package/lib/esm/core/build-static-instance.d.ts +2 -0
- package/lib/esm/core/build-static-instance.js +23 -0
- package/lib/esm/core/create-tree.js +55 -36
- package/lib/esm/features/async-data-loader/feature.js +37 -23
- package/lib/esm/features/async-data-loader/types.d.ts +2 -1
- package/lib/esm/features/drag-and-drop/feature.js +64 -32
- package/lib/esm/features/drag-and-drop/types.d.ts +13 -4
- package/lib/esm/features/drag-and-drop/utils.d.ts +1 -2
- package/lib/esm/features/drag-and-drop/utils.js +138 -34
- package/lib/esm/features/expand-all/feature.js +12 -6
- package/lib/esm/features/main/types.d.ts +8 -2
- package/lib/esm/features/renaming/feature.js +33 -18
- package/lib/esm/features/renaming/types.d.ts +1 -1
- package/lib/esm/features/search/feature.js +38 -24
- package/lib/esm/features/search/types.d.ts +0 -1
- package/lib/esm/features/selection/feature.js +23 -14
- package/lib/esm/features/sync-data-loader/feature.js +7 -2
- package/lib/esm/features/tree/feature.d.ts +2 -1
- package/lib/esm/features/tree/feature.js +86 -64
- package/lib/esm/features/tree/types.d.ts +5 -3
- package/lib/esm/index.d.ts +3 -1
- package/lib/esm/index.js +2 -1
- package/lib/esm/test-utils/test-tree-do.d.ts +23 -0
- package/lib/esm/test-utils/test-tree-do.js +95 -0
- package/lib/esm/test-utils/test-tree-expect.d.ts +15 -0
- package/lib/esm/test-utils/test-tree-expect.js +58 -0
- package/lib/esm/test-utils/test-tree.d.ts +47 -0
- package/lib/esm/test-utils/test-tree.js +191 -0
- package/lib/esm/types/core.d.ts +31 -15
- package/lib/esm/utilities/errors.d.ts +1 -0
- package/lib/esm/utilities/errors.js +1 -0
- package/lib/esm/utilities/insert-items-at-target.js +10 -3
- package/lib/esm/utilities/remove-items-from-parents.js +14 -8
- package/lib/esm/utils.d.ts +3 -3
- package/lib/esm/utils.js +3 -3
- package/package.json +7 -3
- package/src/core/build-proxified-instance.ts +115 -0
- package/src/core/build-static-instance.ts +28 -0
- package/src/core/create-tree.ts +60 -62
- package/src/features/async-data-loader/async-data-loader.spec.ts +143 -0
- package/src/features/async-data-loader/feature.ts +33 -31
- package/src/features/async-data-loader/types.ts +3 -1
- package/src/features/drag-and-drop/drag-and-drop.spec.ts +716 -0
- package/src/features/drag-and-drop/feature.ts +109 -85
- package/src/features/drag-and-drop/types.ts +21 -7
- package/src/features/drag-and-drop/utils.ts +196 -55
- package/src/features/expand-all/expand-all.spec.ts +52 -0
- package/src/features/expand-all/feature.ts +8 -12
- package/src/features/hotkeys-core/feature.ts +1 -1
- package/src/features/main/types.ts +14 -1
- package/src/features/renaming/feature.ts +30 -29
- package/src/features/renaming/renaming.spec.ts +125 -0
- package/src/features/renaming/types.ts +1 -1
- package/src/features/search/feature.ts +34 -38
- package/src/features/search/search.spec.ts +115 -0
- package/src/features/search/types.ts +0 -1
- package/src/features/selection/feature.ts +29 -30
- package/src/features/selection/selection.spec.ts +220 -0
- package/src/features/sync-data-loader/feature.ts +8 -11
- package/src/features/tree/feature.ts +82 -87
- package/src/features/tree/tree.spec.ts +515 -0
- package/src/features/tree/types.ts +5 -3
- package/src/index.ts +4 -1
- package/src/test-utils/test-tree-do.ts +136 -0
- package/src/test-utils/test-tree-expect.ts +86 -0
- package/src/test-utils/test-tree.ts +217 -0
- package/src/types/core.ts +92 -33
- package/src/utilities/errors.ts +2 -0
- package/src/utilities/insert-items-at-target.ts +10 -3
- package/src/utilities/remove-items-from-parents.ts +15 -10
- package/src/utils.spec.ts +89 -0
- package/src/utils.ts +6 -6
- package/tsconfig.json +1 -0
- package/vitest.config.ts +6 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* eslint-disable import/no-extraneous-dependencies */
|
|
2
|
+
import { Mock, expect } from "vitest";
|
|
3
|
+
import { DragEvent } from "react";
|
|
4
|
+
import { TestTree } from "./test-tree";
|
|
5
|
+
import { DropTarget } from "../features/drag-and-drop/types";
|
|
6
|
+
|
|
7
|
+
export class TestTreeExpect<T> {
|
|
8
|
+
protected itemInstance(itemId: string) {
|
|
9
|
+
return this.tree.instance.getItemInstance(itemId);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
protected itemProps(itemId: string) {
|
|
13
|
+
return this.itemInstance(itemId).getProps();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor(private tree: TestTree<T>) {}
|
|
17
|
+
|
|
18
|
+
foldersExpanded(...itemIds: string[]) {
|
|
19
|
+
for (const itemId of itemIds) {
|
|
20
|
+
expect(
|
|
21
|
+
this.tree.instance.getItemInstance(itemId).isExpanded(),
|
|
22
|
+
`Expected ${itemId} to be expanded`,
|
|
23
|
+
).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
foldersCollapsed(...itemIds: string[]) {
|
|
28
|
+
for (const itemId of itemIds) {
|
|
29
|
+
expect(
|
|
30
|
+
this.tree.instance.getItemInstance(itemId).isExpanded(),
|
|
31
|
+
`Expected ${itemId} to be collapsed`,
|
|
32
|
+
).toBe(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
hasChildren(itemId: string, children: string[]) {
|
|
37
|
+
const item = this.tree.instance.getItemInstance(itemId);
|
|
38
|
+
const itemChildren = item.getChildren().map((child) => child.getId());
|
|
39
|
+
expect(itemChildren).toEqual(children);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
dropped(draggedItems: string[], target: DropTarget<any>) {
|
|
43
|
+
expect(this.tree.instance.getConfig().onDrop).toBeCalledWith(
|
|
44
|
+
draggedItems.map((id) => this.tree.item(id)),
|
|
45
|
+
target,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
dragOverNotAllowed(itemId: string, event?: DragEvent) {
|
|
50
|
+
const e = event ?? TestTree.dragEvent();
|
|
51
|
+
(e.preventDefault as Mock).mockClear();
|
|
52
|
+
this.itemProps(itemId).onDragOver(e);
|
|
53
|
+
this.itemProps(itemId).onDragOver(e);
|
|
54
|
+
this.itemProps(itemId).onDragOver(e);
|
|
55
|
+
expect(
|
|
56
|
+
e.preventDefault,
|
|
57
|
+
"onDragOver shouldn't call e.preventDefault if drag is not allowed",
|
|
58
|
+
).not.toBeCalled();
|
|
59
|
+
|
|
60
|
+
this.itemProps(itemId).onDrop(e);
|
|
61
|
+
expect(
|
|
62
|
+
e.preventDefault,
|
|
63
|
+
"onDrop shouldn't call e.preventDefault if drag is not allowed",
|
|
64
|
+
).not.toBeCalled();
|
|
65
|
+
expect(
|
|
66
|
+
this.tree.instance.getConfig().onDrop,
|
|
67
|
+
"onDrop handler shouldn't be called if drag is not allowed",
|
|
68
|
+
).not.toBeCalled();
|
|
69
|
+
return e;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
defaultDragLineProps(indent = 0) {
|
|
73
|
+
expect(this.tree.instance.getDragLineData()).toEqual({
|
|
74
|
+
indent,
|
|
75
|
+
left: indent * 20,
|
|
76
|
+
right: 100,
|
|
77
|
+
top: 0,
|
|
78
|
+
});
|
|
79
|
+
expect(this.tree.instance.getDragLineStyle(0, 0)).toEqual({
|
|
80
|
+
left: `${indent * 20}px`,
|
|
81
|
+
pointerEvents: "none",
|
|
82
|
+
top: "0px",
|
|
83
|
+
width: `${100 - indent * 20}px`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* eslint-disable import/no-extraneous-dependencies */
|
|
2
|
+
import { beforeEach, describe, vi } from "vitest";
|
|
3
|
+
import { DragEvent } from "react";
|
|
4
|
+
import { TreeConfig, TreeInstance } from "../types/core";
|
|
5
|
+
import { createTree } from "../core/create-tree";
|
|
6
|
+
import { TestTreeDo } from "./test-tree-do";
|
|
7
|
+
import { TestTreeExpect } from "./test-tree-expect";
|
|
8
|
+
import { syncDataLoaderFeature } from "../features/sync-data-loader/feature";
|
|
9
|
+
import { asyncDataLoaderFeature } from "../features/async-data-loader/feature";
|
|
10
|
+
import { buildProxiedInstance } from "../core/build-proxified-instance";
|
|
11
|
+
|
|
12
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
13
|
+
|
|
14
|
+
export class TestTree<T = string> {
|
|
15
|
+
public readonly do = new TestTreeDo(this);
|
|
16
|
+
|
|
17
|
+
public readonly expect = new TestTreeExpect(this);
|
|
18
|
+
|
|
19
|
+
private treeInstance: TreeInstance<T> | null = null;
|
|
20
|
+
|
|
21
|
+
private static asyncLoaderResolvers: (() => void)[] = [];
|
|
22
|
+
|
|
23
|
+
suits = {
|
|
24
|
+
sync: () => ({
|
|
25
|
+
tree: this.withFeatures(syncDataLoaderFeature),
|
|
26
|
+
title: "Synchronous Data Loader",
|
|
27
|
+
}),
|
|
28
|
+
async: () => ({
|
|
29
|
+
tree: this.withFeatures(asyncDataLoaderFeature),
|
|
30
|
+
title: "Asynchronous Data Loader",
|
|
31
|
+
}),
|
|
32
|
+
proxifiedSync: () => ({
|
|
33
|
+
tree: this.withFeatures(syncDataLoaderFeature).with({
|
|
34
|
+
instanceBuilder: buildProxiedInstance,
|
|
35
|
+
}),
|
|
36
|
+
title: "Proxified Synchronous Data Loader",
|
|
37
|
+
}),
|
|
38
|
+
proxifiedAsync: () => ({
|
|
39
|
+
tree: this.withFeatures(asyncDataLoaderFeature).with({
|
|
40
|
+
instanceBuilder: buildProxiedInstance,
|
|
41
|
+
}),
|
|
42
|
+
title: "Proxified Asynchronous Data Loader",
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
forSuits(runSuite: (tree: TestTree<T>) => void) {
|
|
47
|
+
describe.for([
|
|
48
|
+
this.suits.sync(),
|
|
49
|
+
this.suits.async(),
|
|
50
|
+
this.suits.proxifiedSync(),
|
|
51
|
+
this.suits.proxifiedAsync(),
|
|
52
|
+
])("$title", ({ tree }) => {
|
|
53
|
+
tree.resetBeforeEach();
|
|
54
|
+
runSuite(tree);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get instance() {
|
|
59
|
+
if (!this.treeInstance) {
|
|
60
|
+
this.treeInstance = createTree(this.config);
|
|
61
|
+
}
|
|
62
|
+
return this.treeInstance;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private constructor(private config: TreeConfig<T>) {}
|
|
66
|
+
|
|
67
|
+
static async resolveAsyncLoaders() {
|
|
68
|
+
while (TestTree.asyncLoaderResolvers.length) {
|
|
69
|
+
TestTree.asyncLoaderResolvers.shift()?.();
|
|
70
|
+
await new Promise<void>((r) => {
|
|
71
|
+
setTimeout(r);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async resolveAsyncVisibleItems() {
|
|
77
|
+
this.instance.getItems();
|
|
78
|
+
await TestTree.resolveAsyncLoaders();
|
|
79
|
+
this.instance.getItems().forEach((i) => i.getItemName());
|
|
80
|
+
await TestTree.resolveAsyncLoaders();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static default(config: Partial<TreeConfig<string>>) {
|
|
84
|
+
return new TestTree({
|
|
85
|
+
rootItemId: "x",
|
|
86
|
+
createLoadingItemData: () => "loading",
|
|
87
|
+
dataLoader: {
|
|
88
|
+
getItem: (id) => id,
|
|
89
|
+
getChildren: (id) => [`${id}1`, `${id}2`, `${id}3`, `${id}4`],
|
|
90
|
+
},
|
|
91
|
+
asyncDataLoader: {
|
|
92
|
+
getItem: async (id) => {
|
|
93
|
+
await new Promise<void>((r) => {
|
|
94
|
+
(r as any).debugName = `Loading getItem ${id}`;
|
|
95
|
+
TestTree.asyncLoaderResolvers.push(r);
|
|
96
|
+
});
|
|
97
|
+
return id;
|
|
98
|
+
},
|
|
99
|
+
getChildren: async (id) => {
|
|
100
|
+
await new Promise<void>((r) => {
|
|
101
|
+
(r as any).debugName = `Loading getChildren ${id}`;
|
|
102
|
+
TestTree.asyncLoaderResolvers.push(r);
|
|
103
|
+
});
|
|
104
|
+
return [`${id}1`, `${id}2`, `${id}3`, `${id}4`];
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
getItemName: (item) => item.getItemData(),
|
|
108
|
+
indent: 20,
|
|
109
|
+
isItemFolder: (item) => item.getItemMeta().level < 2,
|
|
110
|
+
initialState: {
|
|
111
|
+
expandedItems: ["x1", "x11"],
|
|
112
|
+
},
|
|
113
|
+
features: [],
|
|
114
|
+
...config,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
with(config: Partial<TreeConfig<T>>) {
|
|
119
|
+
return new TestTree({ ...this.config, ...config });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resetBeforeEach() {
|
|
123
|
+
beforeEach(async () => {
|
|
124
|
+
await this.createTestCaseTree();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async createTestCaseTree() {
|
|
129
|
+
this.reset();
|
|
130
|
+
vi.clearAllMocks();
|
|
131
|
+
// trigger instance creation
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
133
|
+
this.instance;
|
|
134
|
+
await this.resolveAsyncVisibleItems();
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
withFeatures(...features: any) {
|
|
139
|
+
return this.with({
|
|
140
|
+
features: [...(this.config.features ?? []), ...features],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
mockedHandler(handlerName: keyof TreeConfig<T>) {
|
|
145
|
+
const mock = vi.fn();
|
|
146
|
+
if (this.treeInstance) {
|
|
147
|
+
this.treeInstance?.setConfig((prev) => ({
|
|
148
|
+
...prev,
|
|
149
|
+
[handlerName as any]: mock,
|
|
150
|
+
}));
|
|
151
|
+
} else {
|
|
152
|
+
(this.config as any)[handlerName as any] = mock;
|
|
153
|
+
}
|
|
154
|
+
return mock;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
item(itemId: string) {
|
|
158
|
+
return this.instance.getItemInstance(itemId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
reset() {
|
|
162
|
+
this.treeInstance = null;
|
|
163
|
+
TestTree.asyncLoaderResolvers = [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
debug() {
|
|
167
|
+
console.log(
|
|
168
|
+
this.instance
|
|
169
|
+
.getItems()
|
|
170
|
+
.map((item) =>
|
|
171
|
+
[
|
|
172
|
+
" ".repeat(item.getItemMeta().level),
|
|
173
|
+
'"',
|
|
174
|
+
item.getItemName(),
|
|
175
|
+
'"',
|
|
176
|
+
].join(""),
|
|
177
|
+
)
|
|
178
|
+
.join("\n"),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setElementBoundingBox(
|
|
183
|
+
itemId: string,
|
|
184
|
+
bb: Partial<DOMRect> = {
|
|
185
|
+
left: 0,
|
|
186
|
+
right: 100,
|
|
187
|
+
top: 0,
|
|
188
|
+
height: 20,
|
|
189
|
+
},
|
|
190
|
+
) {
|
|
191
|
+
this.instance.getItemInstance(itemId).registerElement({
|
|
192
|
+
getBoundingClientRect: () => bb as DOMRect,
|
|
193
|
+
} as HTMLElement);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
static dragEvent(pageX = 1000, pageY = 0) {
|
|
197
|
+
return {
|
|
198
|
+
preventDefault: vi.fn(),
|
|
199
|
+
stopPropagation: vi.fn(),
|
|
200
|
+
dataTransfer: {
|
|
201
|
+
setData: vi.fn(),
|
|
202
|
+
getData: vi.fn(),
|
|
203
|
+
dropEffect: "unchaged-from-test",
|
|
204
|
+
},
|
|
205
|
+
pageX,
|
|
206
|
+
pageY,
|
|
207
|
+
} as unknown as DragEvent;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
createTopDragEvent(indent = 0) {
|
|
211
|
+
return TestTree.dragEvent(indent * 20, 1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
createBottomDragEvent(indent = 0) {
|
|
215
|
+
return TestTree.dragEvent(indent * 20, 19);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/types/core.ts
CHANGED
|
@@ -15,11 +15,13 @@ import { ExpandAllFeatureDef } from "../features/expand-all/types";
|
|
|
15
15
|
export type Updater<T> = T | ((old: T) => T);
|
|
16
16
|
export type SetStateFn<T> = (updaterOrValue: Updater<T>) => void;
|
|
17
17
|
|
|
18
|
+
type FunctionMap = Record<string, (...args: any[]) => any>;
|
|
19
|
+
|
|
18
20
|
export type FeatureDef = {
|
|
19
|
-
state:
|
|
20
|
-
config:
|
|
21
|
-
treeInstance:
|
|
22
|
-
itemInstance:
|
|
21
|
+
state: any;
|
|
22
|
+
config: any;
|
|
23
|
+
treeInstance: FunctionMap;
|
|
24
|
+
itemInstance: FunctionMap;
|
|
23
25
|
hotkeys: string;
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -52,7 +54,8 @@ export type FeatureDefs<T> =
|
|
|
52
54
|
| ExpandAllFeatureDef;
|
|
53
55
|
|
|
54
56
|
type MergedFeatures<F extends FeatureDef> = {
|
|
55
|
-
//
|
|
57
|
+
// type can't be removed because it's used for individual feature sets as feature deps in feature implementations
|
|
58
|
+
// to my future self, yes I'm already aware this sounds dumb when I first write this
|
|
56
59
|
state: UnionToIntersection<F["state"]>;
|
|
57
60
|
config: UnionToIntersection<F["config"]>;
|
|
58
61
|
treeInstance: UnionToIntersection<F["treeInstance"]>;
|
|
@@ -123,62 +126,118 @@ export type CustomHotkeysConfig<
|
|
|
123
126
|
Record<HotkeyName<F> | `custom${string}`, Partial<HotkeyConfig<T>>>
|
|
124
127
|
>;
|
|
125
128
|
|
|
129
|
+
type MayReturnNull<T extends (...x: any[]) => any> = (
|
|
130
|
+
...args: Parameters<T>
|
|
131
|
+
) => ReturnType<T> | null;
|
|
132
|
+
|
|
133
|
+
export type ItemInstanceOpts<
|
|
134
|
+
ItemInstance extends FunctionMap = FunctionMap,
|
|
135
|
+
TreeInstance extends FunctionMap = FunctionMap,
|
|
136
|
+
Key extends keyof ItemInstance = any,
|
|
137
|
+
> = {
|
|
138
|
+
item: ItemInstance;
|
|
139
|
+
tree: TreeInstance;
|
|
140
|
+
itemId: string;
|
|
141
|
+
prev?: MayReturnNull<ItemInstance[Key]>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type TreeInstanceOpts<
|
|
145
|
+
TreeInstance extends FunctionMap = FunctionMap,
|
|
146
|
+
Key extends keyof TreeInstance = any,
|
|
147
|
+
> = {
|
|
148
|
+
tree: TreeInstance;
|
|
149
|
+
prev?: MayReturnNull<TreeInstance[Key]>;
|
|
150
|
+
};
|
|
151
|
+
|
|
126
152
|
export type FeatureImplementation<
|
|
127
153
|
T = any,
|
|
128
|
-
|
|
129
|
-
|
|
154
|
+
SelfFeatureDef extends FeatureDef = any,
|
|
155
|
+
DepFeaturesDef extends FeatureDef = any,
|
|
156
|
+
// /** @internal */
|
|
157
|
+
// AllFeatures extends FeatureDef = MergedFeatures<
|
|
158
|
+
// DepFeaturesDef | SelfFeatureDef
|
|
159
|
+
// >,
|
|
160
|
+
// /** @internal */
|
|
161
|
+
// DepFeatures extends FeatureDef = MergedFeatures<DepFeaturesDef>,
|
|
130
162
|
> = {
|
|
131
163
|
key?: string;
|
|
132
164
|
deps?: string[];
|
|
133
165
|
overwrites?: string[];
|
|
134
166
|
|
|
135
167
|
stateHandlerNames?: Partial<
|
|
136
|
-
Record<
|
|
168
|
+
Record<
|
|
169
|
+
keyof MergedFeatures<DepFeaturesDef>["state"],
|
|
170
|
+
keyof MergedFeatures<DepFeaturesDef>["config"]
|
|
171
|
+
>
|
|
137
172
|
>;
|
|
138
173
|
|
|
139
174
|
getInitialState?: (
|
|
140
|
-
initialState: Partial<MergedFeatures<
|
|
141
|
-
tree: MergedFeatures<
|
|
142
|
-
) => Partial<
|
|
175
|
+
initialState: Partial<MergedFeatures<DepFeaturesDef>["state"]>,
|
|
176
|
+
tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
177
|
+
) => Partial<
|
|
178
|
+
SelfFeatureDef["state"] & MergedFeatures<DepFeaturesDef>["state"]
|
|
179
|
+
>;
|
|
143
180
|
|
|
144
181
|
getDefaultConfig?: (
|
|
145
|
-
defaultConfig: Partial<MergedFeatures<
|
|
146
|
-
tree: MergedFeatures<
|
|
147
|
-
) => Partial<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
defaultConfig: Partial<MergedFeatures<DepFeaturesDef>["config"]>,
|
|
183
|
+
tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
184
|
+
) => Partial<
|
|
185
|
+
SelfFeatureDef["config"] & MergedFeatures<DepFeaturesDef>["config"]
|
|
186
|
+
>;
|
|
187
|
+
|
|
188
|
+
treeInstance?: {
|
|
189
|
+
[key in keyof (SelfFeatureDef["treeInstance"] &
|
|
190
|
+
MergedFeatures<DepFeaturesDef>["treeInstance"])]?: (
|
|
191
|
+
opts: TreeInstanceOpts<
|
|
192
|
+
SelfFeatureDef["treeInstance"] &
|
|
193
|
+
MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
194
|
+
key
|
|
195
|
+
>,
|
|
196
|
+
...args: Parameters<
|
|
197
|
+
(SelfFeatureDef["treeInstance"] &
|
|
198
|
+
MergedFeatures<DepFeaturesDef>["treeInstance"])[key]
|
|
199
|
+
>
|
|
200
|
+
) => void;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
itemInstance?: {
|
|
204
|
+
[key in keyof (SelfFeatureDef["itemInstance"] &
|
|
205
|
+
MergedFeatures<DepFeaturesDef>["itemInstance"])]?: (
|
|
206
|
+
opts: ItemInstanceOpts<
|
|
207
|
+
SelfFeatureDef["itemInstance"] &
|
|
208
|
+
MergedFeatures<DepFeaturesDef>["itemInstance"],
|
|
209
|
+
SelfFeatureDef["treeInstance"] &
|
|
210
|
+
MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
211
|
+
key
|
|
212
|
+
>,
|
|
213
|
+
...args: Parameters<
|
|
214
|
+
(SelfFeatureDef["itemInstance"] &
|
|
215
|
+
MergedFeatures<DepFeaturesDef>["itemInstance"])[key]
|
|
216
|
+
>
|
|
217
|
+
) => void;
|
|
218
|
+
};
|
|
160
219
|
|
|
161
220
|
onTreeMount?: (
|
|
162
|
-
instance: MergedFeatures<
|
|
221
|
+
instance: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
163
222
|
treeElement: HTMLElement,
|
|
164
223
|
) => void;
|
|
165
224
|
|
|
166
225
|
onTreeUnmount?: (
|
|
167
|
-
instance: MergedFeatures<
|
|
226
|
+
instance: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
168
227
|
treeElement: HTMLElement,
|
|
169
228
|
) => void;
|
|
170
229
|
|
|
171
230
|
onItemMount?: (
|
|
172
|
-
instance: MergedFeatures<
|
|
231
|
+
instance: MergedFeatures<DepFeaturesDef>["itemInstance"],
|
|
173
232
|
itemElement: HTMLElement,
|
|
174
|
-
tree: MergedFeatures<
|
|
233
|
+
tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
175
234
|
) => void;
|
|
176
235
|
|
|
177
236
|
onItemUnmount?: (
|
|
178
|
-
instance: MergedFeatures<
|
|
237
|
+
instance: MergedFeatures<DepFeaturesDef>["itemInstance"],
|
|
179
238
|
itemElement: HTMLElement,
|
|
180
|
-
tree: MergedFeatures<
|
|
239
|
+
tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
|
|
181
240
|
) => void;
|
|
182
241
|
|
|
183
|
-
hotkeys?: HotkeysConfig<T,
|
|
242
|
+
hotkeys?: HotkeysConfig<T, SelfFeatureDef>;
|
|
184
243
|
};
|
|
@@ -8,11 +8,15 @@ export const insertItemsAtTarget = <T>(
|
|
|
8
8
|
) => {
|
|
9
9
|
// add moved items to new common parent, if dropped onto parent
|
|
10
10
|
if (target.childIndex === null) {
|
|
11
|
-
|
|
11
|
+
const newChildren = [
|
|
12
12
|
...target.item.getChildren().map((item) => item.getId()),
|
|
13
13
|
...itemIds,
|
|
14
|
-
]
|
|
15
|
-
|
|
14
|
+
];
|
|
15
|
+
onChangeChildren(target.item, newChildren);
|
|
16
|
+
if (target.item && "updateCachedChildrenIds" in target.item) {
|
|
17
|
+
target.item.updateCachedChildrenIds(newChildren);
|
|
18
|
+
}
|
|
19
|
+
target.item.getTree().rebuildTree();
|
|
16
20
|
return;
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -26,5 +30,8 @@ export const insertItemsAtTarget = <T>(
|
|
|
26
30
|
|
|
27
31
|
onChangeChildren(target.item, newChildren);
|
|
28
32
|
|
|
33
|
+
if (target.item && "updateCachedChildrenIds" in target.item) {
|
|
34
|
+
target.item.updateCachedChildrenIds(newChildren);
|
|
35
|
+
}
|
|
29
36
|
target.item.getTree().rebuildTree();
|
|
30
37
|
};
|
|
@@ -4,16 +4,21 @@ export const removeItemsFromParents = <T>(
|
|
|
4
4
|
movedItems: ItemInstance<T>[],
|
|
5
5
|
onChangeChildren: (item: ItemInstance<T>, newChildrenIds: string[]) => void,
|
|
6
6
|
) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
const movedItemsIds = movedItems.map((item) => item.getId());
|
|
8
|
+
const uniqueParents = [
|
|
9
|
+
...new Set(movedItems.map((item) => item.getParent())),
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
for (const parent of uniqueParents) {
|
|
13
|
+
const siblings = parent?.getChildren();
|
|
14
|
+
if (siblings && parent) {
|
|
15
|
+
const newChildren = siblings
|
|
16
|
+
.filter((sibling) => !movedItemsIds.includes(sibling.getId()))
|
|
17
|
+
.map((i) => i.getId());
|
|
18
|
+
onChangeChildren(parent, newChildren);
|
|
19
|
+
if (parent && "updateCachedChildrenIds" in parent) {
|
|
20
|
+
parent?.updateCachedChildrenIds(newChildren);
|
|
21
|
+
}
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
24
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { makeStateUpdater, memo, poll } from "./utils";
|
|
3
|
+
|
|
4
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
5
|
+
|
|
6
|
+
describe("utilities", () => {
|
|
7
|
+
describe("memo", () => {
|
|
8
|
+
it("returns same value for same arguments", () => {
|
|
9
|
+
const fn = vi.fn(
|
|
10
|
+
(a: number, b: number, c: number, d: number) => a + b + c + d,
|
|
11
|
+
);
|
|
12
|
+
const memoized = memo((c: number, d: number) => [1, 1, c, d], fn);
|
|
13
|
+
expect(memoized(1, 1)).toBe(4);
|
|
14
|
+
expect(memoized(1, 1)).toBe(4);
|
|
15
|
+
expect(memoized(1, 1)).toBe(4);
|
|
16
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns different values for different arguments", () => {
|
|
20
|
+
const fn = vi.fn(
|
|
21
|
+
(a: number, b: number, c: number, d: number) => a + b + c + d,
|
|
22
|
+
);
|
|
23
|
+
const memoized = memo((c: number, d: number) => [1, 1, c, d], fn);
|
|
24
|
+
expect(memoized(1, 1)).toBe(4);
|
|
25
|
+
expect(memoized(1, 2)).toBe(5);
|
|
26
|
+
expect(memoized(1, 2)).toBe(5);
|
|
27
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("makeStateUpdater", () => {
|
|
32
|
+
it("updates the state correctly", () => {
|
|
33
|
+
const instance = {
|
|
34
|
+
setState: vi.fn((updater) => {
|
|
35
|
+
const oldState = { focusedItem: "oldValue" };
|
|
36
|
+
const newState = updater(oldState);
|
|
37
|
+
return newState;
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const updater = makeStateUpdater("focusedItem", instance);
|
|
42
|
+
updater("newValue");
|
|
43
|
+
|
|
44
|
+
expect(instance.setState).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(instance.setState).toHaveBeenCalledWith(expect.any(Function));
|
|
46
|
+
const stateUpdateFn = instance.setState.mock.calls[0][0];
|
|
47
|
+
expect(stateUpdateFn({ focusedItem: "oldValue" })).toEqual({
|
|
48
|
+
focusedItem: "newValue",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("updates the state using a function updater", () => {
|
|
53
|
+
const instance = {
|
|
54
|
+
setState: vi.fn((updater) => {
|
|
55
|
+
const oldState = { focusedItem: "oldValue" };
|
|
56
|
+
const newState = updater(oldState);
|
|
57
|
+
return newState;
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const updater = makeStateUpdater("focusedItem", instance);
|
|
62
|
+
updater((prev) => `${prev}Updated`);
|
|
63
|
+
|
|
64
|
+
expect(instance.setState).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(instance.setState).toHaveBeenCalledWith(expect.any(Function));
|
|
66
|
+
const stateUpdateFn = instance.setState.mock.calls[0][0];
|
|
67
|
+
expect(stateUpdateFn({ focusedItem: "oldValue" })).toEqual({
|
|
68
|
+
focusedItem: "oldValueUpdated",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("poll", () => {
|
|
74
|
+
it("resolves when the condition is met within the timeout", async () => {
|
|
75
|
+
const condition = vi
|
|
76
|
+
.fn()
|
|
77
|
+
.mockReturnValueOnce(false)
|
|
78
|
+
.mockReturnValueOnce(true);
|
|
79
|
+
await expect(poll(condition, 50, 200)).resolves.toBeUndefined();
|
|
80
|
+
expect(condition).toHaveBeenCalledTimes(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("resolves immediately if the condition is already met", async () => {
|
|
84
|
+
const condition = vi.fn().mockReturnValue(true);
|
|
85
|
+
await expect(poll(condition, 50, 200)).resolves.toBeUndefined();
|
|
86
|
+
expect(condition).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { TreeState, Updater } from "./types/core";
|
|
1
|
+
import { SetStateFn, TreeState, Updater } from "./types/core";
|
|
2
2
|
|
|
3
3
|
export type NoInfer<T> = [T][T extends any ? 0 : never];
|
|
4
4
|
|
|
5
|
-
export const memo = <D extends readonly any[], R>(
|
|
5
|
+
export const memo = <D extends readonly any[], P extends readonly any[], R>(
|
|
6
|
+
deps: (...args: [...P]) => [...D],
|
|
6
7
|
fn: (...args: [...D]) => R,
|
|
7
|
-
deps: () => [...D],
|
|
8
8
|
) => {
|
|
9
9
|
let value: R | undefined;
|
|
10
10
|
let oldDeps: D | null = null;
|
|
11
11
|
|
|
12
|
-
return () => {
|
|
13
|
-
const newDeps = deps();
|
|
12
|
+
return (...a: [...P]) => {
|
|
13
|
+
const newDeps = deps(...a);
|
|
14
14
|
|
|
15
15
|
if (!value) {
|
|
16
16
|
value = fn(...newDeps);
|
|
@@ -41,7 +41,7 @@ export function functionalUpdate<T>(updater: Updater<T>, input: T): T {
|
|
|
41
41
|
export function makeStateUpdater<K extends keyof TreeState<any>>(
|
|
42
42
|
key: K,
|
|
43
43
|
instance: unknown,
|
|
44
|
-
) {
|
|
44
|
+
): SetStateFn<TreeState<any>[K]> {
|
|
45
45
|
return (updater: Updater<TreeState<any>[K]>) => {
|
|
46
46
|
(instance as any).setState(<TTableState>(old: TTableState) => {
|
|
47
47
|
return {
|
package/tsconfig.json
CHANGED