react-arborist 3.10.3 → 3.10.5
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/dist/main/data/simple-tree.d.ts +14 -8
- package/dist/main/data/simple-tree.js +34 -15
- package/dist/main/data/simple-tree.test.d.ts +1 -0
- package/dist/main/data/simple-tree.test.js +63 -0
- package/dist/main/hooks/use-simple-tree.d.ts +2 -1
- package/dist/main/hooks/use-simple-tree.js +19 -4
- package/dist/main/hooks/use-simple-tree.test.d.ts +1 -0
- package/dist/main/hooks/use-simple-tree.test.js +32 -0
- package/dist/main/hooks/use-validated-props.js +4 -1
- package/dist/main/interfaces/tree-api.d.ts +33 -20
- package/dist/main/interfaces/tree-api.js +48 -23
- package/dist/main/interfaces/tree-api.test.js +48 -0
- package/dist/main/types/handlers.d.ts +1 -1
- package/dist/module/data/simple-tree.d.ts +14 -8
- package/dist/module/data/simple-tree.js +34 -15
- package/dist/module/data/simple-tree.test.d.ts +1 -0
- package/dist/module/data/simple-tree.test.js +61 -0
- package/dist/module/hooks/use-simple-tree.d.ts +2 -1
- package/dist/module/hooks/use-simple-tree.js +19 -4
- package/dist/module/hooks/use-simple-tree.test.d.ts +1 -0
- package/dist/module/hooks/use-simple-tree.test.js +30 -0
- package/dist/module/hooks/use-validated-props.js +4 -1
- package/dist/module/interfaces/tree-api.d.ts +33 -20
- package/dist/module/interfaces/tree-api.js +48 -23
- package/dist/module/interfaces/tree-api.test.js +48 -0
- package/dist/module/types/handlers.d.ts +1 -1
- package/package.json +1 -1
- package/src/data/simple-tree.test.ts +68 -0
- package/src/data/simple-tree.ts +53 -16
- package/src/hooks/use-simple-tree.test.ts +39 -0
- package/src/hooks/use-simple-tree.ts +26 -8
- package/src/hooks/use-validated-props.ts +4 -1
- package/src/interfaces/tree-api.test.ts +46 -0
- package/src/interfaces/tree-api.ts +68 -41
- package/src/types/handlers.ts +3 -1
|
@@ -11,6 +11,7 @@ import * as utils from "../utils";
|
|
|
11
11
|
import { DefaultCursor } from "../components/default-cursor";
|
|
12
12
|
import { DefaultRow } from "../components/default-row";
|
|
13
13
|
import { DefaultNode } from "../components/default-node";
|
|
14
|
+
import { NodeApi } from "./node-api";
|
|
14
15
|
import { edit } from "../state/edit-slice";
|
|
15
16
|
import { focus, treeBlur } from "../state/focus-slice";
|
|
16
17
|
import { createRoot, ROOT_ID } from "../data/create-root";
|
|
@@ -21,7 +22,7 @@ import { DefaultDragPreview } from "../components/default-drag-preview";
|
|
|
21
22
|
import { DefaultContainer } from "../components/default-container";
|
|
22
23
|
import { createList } from "../data/create-list";
|
|
23
24
|
import { createIndex } from "../data/create-index";
|
|
24
|
-
const { safeRun
|
|
25
|
+
const { safeRun } = utils;
|
|
25
26
|
export class TreeApi {
|
|
26
27
|
constructor(store, props, list, listEl) {
|
|
27
28
|
this.store = store;
|
|
@@ -163,6 +164,30 @@ export class TreeApi {
|
|
|
163
164
|
throw new Error("Data must contain an 'id' property or props.idAccessor must return a string");
|
|
164
165
|
return id;
|
|
165
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve an identifier to a node id. Public methods accept an id string, a
|
|
169
|
+
* NodeApi, or the raw row data; this is the one place that turns any of those
|
|
170
|
+
* into the string id used internally. Raw data is run through the configured
|
|
171
|
+
* `idAccessor` so a custom accessor (e.g. `uuid`) is honored everywhere, not
|
|
172
|
+
* just where nodes were built. A NodeApi already carries its accessor-derived
|
|
173
|
+
* `id`, so it is used directly rather than re-accessed (the accessor reads the
|
|
174
|
+
* underlying data, which a NodeApi does not expose under that key). Unlike
|
|
175
|
+
* `accessId`, an unresolved id comes back as `undefined` rather than throwing,
|
|
176
|
+
* preserving the previous behavior of the `id`-only lookup.
|
|
177
|
+
*/
|
|
178
|
+
identify(identity) {
|
|
179
|
+
if (typeof identity === "string")
|
|
180
|
+
return identity;
|
|
181
|
+
if (identity instanceof NodeApi)
|
|
182
|
+
return identity.id;
|
|
183
|
+
const get = this.props.idAccessor || "id";
|
|
184
|
+
return utils.access(identity, get);
|
|
185
|
+
}
|
|
186
|
+
identifyNull(identity) {
|
|
187
|
+
if (identity === null || identity === undefined)
|
|
188
|
+
return null;
|
|
189
|
+
return this.identify(identity);
|
|
190
|
+
}
|
|
166
191
|
/* Node Access */
|
|
167
192
|
get firstNode() {
|
|
168
193
|
var _a;
|
|
@@ -218,7 +243,7 @@ export class TreeApi {
|
|
|
218
243
|
return this.visibleNodes.slice(start, end + 1);
|
|
219
244
|
}
|
|
220
245
|
indexOf(id) {
|
|
221
|
-
const key =
|
|
246
|
+
const key = this.identifyNull(id);
|
|
222
247
|
if (!key)
|
|
223
248
|
return null;
|
|
224
249
|
return this.idToIndex[key];
|
|
@@ -261,7 +286,7 @@ export class TreeApi {
|
|
|
261
286
|
if (!node)
|
|
262
287
|
return;
|
|
263
288
|
const idents = Array.isArray(node) ? node : [node];
|
|
264
|
-
const ids = idents.map(identify);
|
|
289
|
+
const ids = idents.map((i) => this.identify(i));
|
|
265
290
|
const nodes = ids.map((id) => this.get(id)).filter((n) => !!n);
|
|
266
291
|
/* Guard against Math.min(...[]) === Infinity when no ids resolve to nodes. */
|
|
267
292
|
const fromIndex = nodes.length ? Math.min(...nodes.map((n) => { var _a; return (_a = n.rowIndex) !== null && _a !== void 0 ? _a : 0; })) : 0;
|
|
@@ -271,7 +296,7 @@ export class TreeApi {
|
|
|
271
296
|
}
|
|
272
297
|
edit(node) {
|
|
273
298
|
var _a, _b;
|
|
274
|
-
const id = identify(node);
|
|
299
|
+
const id = this.identify(node);
|
|
275
300
|
this.resolveEdit({ cancelled: true });
|
|
276
301
|
this.scrollTo(id);
|
|
277
302
|
this.dispatch(edit(id));
|
|
@@ -285,7 +310,7 @@ export class TreeApi {
|
|
|
285
310
|
var _a, _b;
|
|
286
311
|
if (!identity)
|
|
287
312
|
return;
|
|
288
|
-
const id = identify(identity);
|
|
313
|
+
const id = this.identify(identity);
|
|
289
314
|
yield safeRun(this.props.onRename, {
|
|
290
315
|
id,
|
|
291
316
|
name: value,
|
|
@@ -304,7 +329,7 @@ export class TreeApi {
|
|
|
304
329
|
setTimeout(() => this.onFocus()); // Return focus to element;
|
|
305
330
|
}
|
|
306
331
|
activate(id) {
|
|
307
|
-
const node = this.get(identifyNull(id));
|
|
332
|
+
const node = this.get(this.identifyNull(id));
|
|
308
333
|
if (!node)
|
|
309
334
|
return;
|
|
310
335
|
safeRun(this.props.onActivate, node);
|
|
@@ -338,7 +363,7 @@ export class TreeApi {
|
|
|
338
363
|
this.select(node);
|
|
339
364
|
}
|
|
340
365
|
else {
|
|
341
|
-
this.dispatch(focus(identify(node)));
|
|
366
|
+
this.dispatch(focus(this.identify(node)));
|
|
342
367
|
if (opts.scroll !== false)
|
|
343
368
|
this.scrollTo(node);
|
|
344
369
|
if (this.focusedNode)
|
|
@@ -378,7 +403,7 @@ export class TreeApi {
|
|
|
378
403
|
if (!node)
|
|
379
404
|
return;
|
|
380
405
|
const changeFocus = opts.focus !== false;
|
|
381
|
-
const id = identify(node);
|
|
406
|
+
const id = this.identify(node);
|
|
382
407
|
if (changeFocus)
|
|
383
408
|
this.dispatch(focus(id));
|
|
384
409
|
if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
|
|
@@ -396,12 +421,12 @@ export class TreeApi {
|
|
|
396
421
|
deselect(node) {
|
|
397
422
|
if (!node)
|
|
398
423
|
return;
|
|
399
|
-
const id = identify(node);
|
|
424
|
+
const id = this.identify(node);
|
|
400
425
|
this.dispatch(selection.remove(id));
|
|
401
426
|
safeRun(this.props.onSelect, this.selectedNodes);
|
|
402
427
|
}
|
|
403
428
|
selectMulti(identity, opts = {}) {
|
|
404
|
-
const node = this.get(identifyNull(identity));
|
|
429
|
+
const node = this.get(this.identifyNull(identity));
|
|
405
430
|
if (!node)
|
|
406
431
|
return;
|
|
407
432
|
const changeFocus = opts.focus !== false;
|
|
@@ -422,11 +447,11 @@ export class TreeApi {
|
|
|
422
447
|
var _a;
|
|
423
448
|
if (!identity)
|
|
424
449
|
return;
|
|
425
|
-
const id = identify(identity);
|
|
450
|
+
const id = this.identify(identity);
|
|
426
451
|
this.dispatch(focus(id));
|
|
427
452
|
if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
|
|
428
453
|
const { anchor, mostRecent } = this.state.nodes.selection;
|
|
429
|
-
const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, identifyNull(id)));
|
|
454
|
+
const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, this.identifyNull(id)));
|
|
430
455
|
this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
|
|
431
456
|
this.dispatch(selection.add(selectableNodes));
|
|
432
457
|
this.dispatch(selection.mostRecent(id));
|
|
@@ -455,14 +480,14 @@ export class TreeApi {
|
|
|
455
480
|
}
|
|
456
481
|
filterSelectableNodes(nodes) {
|
|
457
482
|
return nodes
|
|
458
|
-
.map((n) => this.get(identify(n)))
|
|
483
|
+
.map((n) => this.get(this.identify(n)))
|
|
459
484
|
.filter((n) => !!n && n.isSelectable);
|
|
460
485
|
}
|
|
461
486
|
setSelection(args) {
|
|
462
487
|
var _a;
|
|
463
|
-
const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map(identify));
|
|
464
|
-
const anchor = identifyNull(args.anchor);
|
|
465
|
-
const mostRecent = identifyNull(args.mostRecent);
|
|
488
|
+
const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map((i) => this.identify(i)));
|
|
489
|
+
const anchor = this.identifyNull(args.anchor);
|
|
490
|
+
const mostRecent = this.identifyNull(args.mostRecent);
|
|
466
491
|
this.dispatch(selection.set({ ids, anchor, mostRecent }));
|
|
467
492
|
safeRun(this.props.onSelect, this.selectedNodes);
|
|
468
493
|
}
|
|
@@ -545,7 +570,7 @@ export class TreeApi {
|
|
|
545
570
|
/* Visibility */
|
|
546
571
|
open(identity, redraw = true) {
|
|
547
572
|
var _a, _b;
|
|
548
|
-
const id = identifyNull(identity);
|
|
573
|
+
const id = this.identifyNull(identity);
|
|
549
574
|
if (!id)
|
|
550
575
|
return;
|
|
551
576
|
if (this.isOpen(id))
|
|
@@ -557,7 +582,7 @@ export class TreeApi {
|
|
|
557
582
|
}
|
|
558
583
|
close(identity, redraw = true) {
|
|
559
584
|
var _a, _b;
|
|
560
|
-
const id = identifyNull(identity);
|
|
585
|
+
const id = this.identifyNull(identity);
|
|
561
586
|
if (!id)
|
|
562
587
|
return;
|
|
563
588
|
if (!this.isOpen(id))
|
|
@@ -568,13 +593,13 @@ export class TreeApi {
|
|
|
568
593
|
safeRun(this.props.onToggle, id);
|
|
569
594
|
}
|
|
570
595
|
toggle(identity) {
|
|
571
|
-
const id = identifyNull(identity);
|
|
596
|
+
const id = this.identifyNull(identity);
|
|
572
597
|
if (!id)
|
|
573
598
|
return;
|
|
574
599
|
return this.isOpen(id) ? this.close(id) : this.open(id);
|
|
575
600
|
}
|
|
576
601
|
openParents(identity) {
|
|
577
|
-
const id = identifyNull(identity);
|
|
602
|
+
const id = this.identifyNull(identity);
|
|
578
603
|
if (!id)
|
|
579
604
|
return;
|
|
580
605
|
const node = utils.dfs(this.root, id);
|
|
@@ -622,7 +647,7 @@ export class TreeApi {
|
|
|
622
647
|
scrollTo(identity, align = "smart") {
|
|
623
648
|
if (!identity)
|
|
624
649
|
return;
|
|
625
|
-
const id = identify(identity);
|
|
650
|
+
const id = this.identify(identity);
|
|
626
651
|
this.openParents(id);
|
|
627
652
|
return utils
|
|
628
653
|
.waitFor(() => id in this.idToIndex)
|
|
@@ -689,7 +714,7 @@ export class TreeApi {
|
|
|
689
714
|
return !utils.access(data, disabler);
|
|
690
715
|
}
|
|
691
716
|
isDragging(node) {
|
|
692
|
-
const id = identifyNull(node);
|
|
717
|
+
const id = this.identifyNull(node);
|
|
693
718
|
if (!id)
|
|
694
719
|
return false;
|
|
695
720
|
return this.state.nodes.drag.id === id;
|
|
@@ -701,7 +726,7 @@ export class TreeApi {
|
|
|
701
726
|
return this.matchFn(node);
|
|
702
727
|
}
|
|
703
728
|
willReceiveDrop(node) {
|
|
704
|
-
const id = identifyNull(node);
|
|
729
|
+
const id = this.identifyNull(node);
|
|
705
730
|
if (!id)
|
|
706
731
|
return false;
|
|
707
732
|
const { destinationParentId, destinationIndex } = this.state.nodes.drag;
|
|
@@ -1,3 +1,12 @@
|
|
|
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
|
+
};
|
|
1
10
|
import { createStore } from "redux";
|
|
2
11
|
import { rootReducer } from "../state/root-reducer";
|
|
3
12
|
import { actions as dnd } from "../state/dnd-slice";
|
|
@@ -36,6 +45,45 @@ describe("tree.drop() fires onMove (#313)", () => {
|
|
|
36
45
|
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ parentId: "folder", index: 0 }));
|
|
37
46
|
});
|
|
38
47
|
});
|
|
48
|
+
describe("custom idAccessor is honored when methods receive raw data (#347)", () => {
|
|
49
|
+
const uuidData = [{ uuid: "a" }, { uuid: "b" }, { uuid: "c" }];
|
|
50
|
+
test("select(data) resolves the id through idAccessor", () => {
|
|
51
|
+
const onSelect = jest.fn();
|
|
52
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onSelect });
|
|
53
|
+
api.select(uuidData[1]);
|
|
54
|
+
expect(api.selectedIds.has("b")).toBe(true);
|
|
55
|
+
expect(api.selectedNodes.map((n) => n.id)).toEqual(["b"]);
|
|
56
|
+
});
|
|
57
|
+
test("focus(data) resolves the id through idAccessor", () => {
|
|
58
|
+
var _a;
|
|
59
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid" });
|
|
60
|
+
api.focus(uuidData[2]);
|
|
61
|
+
expect((_a = api.focusedNode) === null || _a === void 0 ? void 0 : _a.id).toBe("c");
|
|
62
|
+
});
|
|
63
|
+
test("delete(data) passes the accessor-derived id to onDelete", () => {
|
|
64
|
+
const onDelete = jest.fn();
|
|
65
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onDelete });
|
|
66
|
+
api.delete(uuidData[0]);
|
|
67
|
+
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ ids: ["a"] }));
|
|
68
|
+
});
|
|
69
|
+
test("create() focuses the new node by its accessor-derived id", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
70
|
+
// create() passes the raw row data returned by onCreate straight to
|
|
71
|
+
// focus/edit/select; before the fix these read `.id` and lost the node.
|
|
72
|
+
const onCreate = () => ({ uuid: "new" });
|
|
73
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onCreate });
|
|
74
|
+
yield api.create();
|
|
75
|
+
expect(api.state.nodes.focus.id).toBe("new");
|
|
76
|
+
}));
|
|
77
|
+
test("a function idAccessor is honored too", () => {
|
|
78
|
+
const fnData = [{ meta: { key: "x" } }, { meta: { key: "y" } }];
|
|
79
|
+
const api = setupApi({
|
|
80
|
+
data: fnData,
|
|
81
|
+
idAccessor: (d) => d.meta.key,
|
|
82
|
+
});
|
|
83
|
+
api.select(fnData[1]);
|
|
84
|
+
expect(api.selectedIds.has("y")).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
39
87
|
test("rowHeight defaults to 24", () => {
|
|
40
88
|
const api = setupApi({});
|
|
41
89
|
expect(api.rowHeight).toBe(24);
|
|
@@ -5,7 +5,7 @@ export type CreateHandler<T> = (args: {
|
|
|
5
5
|
parentNode: NodeApi<T> | null;
|
|
6
6
|
index: number;
|
|
7
7
|
type: "internal" | "leaf";
|
|
8
|
-
}) => (IdObj | null) | Promise<IdObj | null>;
|
|
8
|
+
}) => (T | IdObj | null) | Promise<T | IdObj | null>;
|
|
9
9
|
export type MoveHandler<T> = (args: {
|
|
10
10
|
dragIds: string[];
|
|
11
11
|
dragNodes: NodeApi<T>[];
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { SimpleTree } from "./simple-tree";
|
|
2
|
+
|
|
3
|
+
describe("SimpleTree with default accessors", () => {
|
|
4
|
+
const data = () => [
|
|
5
|
+
{ id: "1", name: "a", children: [{ id: "1a", name: "a-child" }] },
|
|
6
|
+
{ id: "2", name: "b" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
test("finds nodes by id, including nested ones", () => {
|
|
10
|
+
const tree = new SimpleTree(data());
|
|
11
|
+
expect(tree.find("2")?.data.name).toBe("b");
|
|
12
|
+
expect(tree.find("1a")?.data.name).toBe("a-child");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("moves a node into a folder", () => {
|
|
16
|
+
const tree = new SimpleTree(data());
|
|
17
|
+
tree.move({ id: "2", parentId: "1", index: 1 });
|
|
18
|
+
expect(tree.data[0].children!.map((c) => c.id)).toEqual(["1a", "2"]);
|
|
19
|
+
expect(tree.data).toHaveLength(1);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("SimpleTree honors custom accessors (issue #73, #170)", () => {
|
|
24
|
+
// Custom keys: `uuid` for the id, `elements` for the children.
|
|
25
|
+
const data = () => [
|
|
26
|
+
{ uuid: "1", name: "a", elements: [{ uuid: "1a", name: "a-child" }] },
|
|
27
|
+
{ uuid: "2", name: "b" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function tree() {
|
|
31
|
+
return new SimpleTree(data(), { idAccessor: "uuid", childrenAccessor: "elements" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("finds nodes by the custom id key, including nested ones", () => {
|
|
35
|
+
const t = tree();
|
|
36
|
+
expect(t.find("2")?.data.name).toBe("b");
|
|
37
|
+
expect(t.find("1a")?.data.name).toBe("a-child"); // read through `elements`
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("reorders a node, writing children back under the custom key (#170)", () => {
|
|
41
|
+
const t = tree();
|
|
42
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
43
|
+
expect(t.data).toHaveLength(1);
|
|
44
|
+
expect(t.data[0].elements!.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("moving a node with children into a childless node keeps its children (#73)", () => {
|
|
48
|
+
const t = tree();
|
|
49
|
+
// Put node "1" (which has children) inside node "2" (which has none).
|
|
50
|
+
t.move({ id: "1", parentId: "2", index: 0 });
|
|
51
|
+
expect(t.data.map((n) => n.uuid)).toEqual(["2"]);
|
|
52
|
+
const moved = t.find("1");
|
|
53
|
+
expect(moved?.data.elements!.map((c: any) => c.uuid)).toEqual(["1a"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("supports a function idAccessor", () => {
|
|
57
|
+
const t = new SimpleTree(data(), { idAccessor: (d) => d.uuid, childrenAccessor: "elements" });
|
|
58
|
+
expect(t.find("1a")?.data.name).toBe("a-child");
|
|
59
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
60
|
+
expect(t.data[0].elements!.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a function idAccessor that reaches into the data doesn't throw on construction", () => {
|
|
64
|
+
const nested = [{ meta: { id: "x" }, name: "x" }];
|
|
65
|
+
// The synthetic root must not run this accessor on its empty data.
|
|
66
|
+
expect(() => new SimpleTree(nested, { idAccessor: (d) => d.meta.id })).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/data/simple-tree.ts
CHANGED
|
@@ -1,9 +1,37 @@
|
|
|
1
|
-
type
|
|
1
|
+
export type SimpleTreeOptions<T> = {
|
|
2
|
+
idAccessor?: string | ((d: T) => string);
|
|
3
|
+
childrenAccessor?: string | ((d: T) => readonly T[] | null | undefined);
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
/* Resolved id/children readers plus the string key the controller writes
|
|
7
|
+
children back under. A string accessor is used for both reading and writing;
|
|
8
|
+
a function accessor can only be read, so writes fall back to "children".
|
|
9
|
+
This is what lets initialData honor idAccessor/childrenAccessor (issue #73):
|
|
10
|
+
without it, the controller assumed `id`/`children` and silently dropped moves
|
|
11
|
+
for trees keyed differently. */
|
|
12
|
+
type Accessors<T> = {
|
|
13
|
+
getId: (data: T) => string;
|
|
14
|
+
getChildren: (data: T) => readonly T[] | null | undefined;
|
|
15
|
+
childrenKey: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function resolveAccessors<T>(options: SimpleTreeOptions<T> = {}): Accessors<T> {
|
|
19
|
+
const id = options.idAccessor ?? "id";
|
|
20
|
+
const children = options.childrenAccessor ?? "children";
|
|
21
|
+
return {
|
|
22
|
+
getId: typeof id === "function" ? id : (data) => (data as any)[id],
|
|
23
|
+
getChildren: typeof children === "function" ? children : (data) => (data as any)[children],
|
|
24
|
+
childrenKey: typeof children === "string" ? children : "children",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
2
27
|
|
|
3
|
-
export class SimpleTree<T
|
|
28
|
+
export class SimpleTree<T> {
|
|
4
29
|
root: SimpleNode<T>;
|
|
5
|
-
|
|
6
|
-
|
|
30
|
+
private accessors: Accessors<T>;
|
|
31
|
+
|
|
32
|
+
constructor(data: T[], options: SimpleTreeOptions<T> = {}) {
|
|
33
|
+
this.accessors = resolveAccessors(options);
|
|
34
|
+
this.root = createRoot<T>(data, this.accessors);
|
|
7
35
|
}
|
|
8
36
|
|
|
9
37
|
get data() {
|
|
@@ -48,26 +76,32 @@ export class SimpleTree<T extends SimpleData> {
|
|
|
48
76
|
}
|
|
49
77
|
}
|
|
50
78
|
|
|
51
|
-
function createRoot<T
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
function createRoot<T>(data: T[], accessors: Accessors<T>) {
|
|
80
|
+
// The synthetic root has no real data, so it gets an explicit id rather than
|
|
81
|
+
// running the user's accessor on `{}` — a function accessor that reaches into
|
|
82
|
+
// the data (e.g. `d => d.meta.id`) would otherwise throw during construction.
|
|
83
|
+
const root = new SimpleNode<T>({} as T, null, accessors, "ROOT");
|
|
84
|
+
root.children = data.map((d) => createNode(d, root, accessors));
|
|
54
85
|
return root;
|
|
55
86
|
}
|
|
56
87
|
|
|
57
|
-
function createNode<T
|
|
58
|
-
const node = new SimpleNode<T>(data, parent);
|
|
59
|
-
|
|
88
|
+
function createNode<T>(data: T, parent: SimpleNode<T>, accessors: Accessors<T>) {
|
|
89
|
+
const node = new SimpleNode<T>(data, parent, accessors);
|
|
90
|
+
const children = accessors.getChildren(data);
|
|
91
|
+
if (children) node.children = children.map((d) => createNode<T>(d, node, accessors));
|
|
60
92
|
return node;
|
|
61
93
|
}
|
|
62
94
|
|
|
63
|
-
class SimpleNode<T
|
|
95
|
+
class SimpleNode<T> {
|
|
64
96
|
id: string;
|
|
65
97
|
children?: SimpleNode<T>[];
|
|
66
98
|
constructor(
|
|
67
99
|
public data: T,
|
|
68
100
|
public parent: SimpleNode<T> | null,
|
|
101
|
+
private accessors: Accessors<T>,
|
|
102
|
+
id?: string,
|
|
69
103
|
) {
|
|
70
|
-
this.id = data
|
|
104
|
+
this.id = id ?? accessors.getId(data);
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
hasParent(): this is this & { parent: SimpleNode<T> } {
|
|
@@ -79,16 +113,19 @@ class SimpleNode<T extends SimpleData> {
|
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
addChild(data: T, index: number) {
|
|
82
|
-
const node = createNode(data, this);
|
|
116
|
+
const node = createNode(data, this, this.accessors);
|
|
83
117
|
this.children = this.children ?? [];
|
|
84
118
|
this.children.splice(index, 0, node);
|
|
85
|
-
|
|
86
|
-
this.data
|
|
119
|
+
const key = this.accessors.childrenKey;
|
|
120
|
+
const raw = this.data as any;
|
|
121
|
+
raw[key] = raw[key] ?? [];
|
|
122
|
+
raw[key].splice(index, 0, data);
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
removeChild(index: number) {
|
|
90
126
|
this.children?.splice(index, 1);
|
|
91
|
-
this.data
|
|
127
|
+
const raw = this.data as any;
|
|
128
|
+
raw[this.accessors.childrenKey]?.splice(index, 1);
|
|
92
129
|
}
|
|
93
130
|
|
|
94
131
|
update(changes: Partial<T>) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { useSimpleTree } from "./use-simple-tree";
|
|
3
|
+
|
|
4
|
+
/* onCreate has to write a new node's id (and a folder's children) under a key
|
|
5
|
+
the accessors will read back. A function accessor can't be inverted to a key,
|
|
6
|
+
so creation with one must fail fast rather than return an unusable node
|
|
7
|
+
(issue #73 review follow-up). */
|
|
8
|
+
describe("useSimpleTree onCreate guards function accessors", () => {
|
|
9
|
+
function controllerFor<T>(data: T[], options: Parameters<typeof useSimpleTree<T>>[1]) {
|
|
10
|
+
const { result } = renderHook(() => useSimpleTree<T>(data, options));
|
|
11
|
+
return result.current[1];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const create = { parentId: null, parentNode: null, index: 0 } as const;
|
|
15
|
+
|
|
16
|
+
test("throws when idAccessor is a function", () => {
|
|
17
|
+
const controller = controllerFor([{ uuid: "1", name: "a" }], { idAccessor: (d) => d.uuid });
|
|
18
|
+
expect(() => controller.onCreate({ ...create, type: "leaf" })).toThrow(
|
|
19
|
+
/idAccessor is a function/,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("throws when creating a folder with a function childrenAccessor", () => {
|
|
24
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
25
|
+
childrenAccessor: (d) => (d as any).kids,
|
|
26
|
+
});
|
|
27
|
+
expect(() => controller.onCreate({ ...create, type: "internal" })).toThrow(
|
|
28
|
+
/childrenAccessor is a function/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a leaf can still be created when only childrenAccessor is a function", () => {
|
|
33
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
34
|
+
childrenAccessor: (d) => (d as any).kids,
|
|
35
|
+
});
|
|
36
|
+
// onCreate calls setData, so run it inside act to keep the suite warning-clean.
|
|
37
|
+
expect(() => act(() => void controller.onCreate({ ...create, type: "leaf" }))).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
|
-
import { SimpleTree } from "../data/simple-tree";
|
|
2
|
+
import { SimpleTree, SimpleTreeOptions } from "../data/simple-tree";
|
|
3
3
|
import { CreateHandler, DeleteHandler, MoveHandler, RenameHandler } from "../types/handlers";
|
|
4
4
|
|
|
5
5
|
export type SimpleTreeData = {
|
|
@@ -10,13 +10,13 @@ export type SimpleTreeData = {
|
|
|
10
10
|
|
|
11
11
|
let nextId = 0;
|
|
12
12
|
|
|
13
|
-
export function useSimpleTree<T>(initialData: readonly T[]) {
|
|
13
|
+
export function useSimpleTree<T>(initialData: readonly T[], options: SimpleTreeOptions<T> = {}) {
|
|
14
14
|
const [data, setData] = useState(initialData);
|
|
15
|
+
const idAccessor = options.idAccessor;
|
|
16
|
+
const childrenAccessor = options.childrenAccessor;
|
|
15
17
|
const tree = useMemo(
|
|
16
|
-
() =>
|
|
17
|
-
|
|
18
|
-
T>(data),
|
|
19
|
-
[data],
|
|
18
|
+
() => new SimpleTree<T>(data as T[], { idAccessor, childrenAccessor }),
|
|
19
|
+
[data, idAccessor, childrenAccessor],
|
|
20
20
|
);
|
|
21
21
|
|
|
22
22
|
const onMove: MoveHandler<T> = (args: {
|
|
@@ -35,9 +35,27 @@ export function useSimpleTree<T>(initialData: readonly T[]) {
|
|
|
35
35
|
setData(tree.data);
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// New nodes must carry their id/children under the same keys the accessors
|
|
39
|
+
// read, or the controller (and the tree's own accessId) can't find them
|
|
40
|
+
// afterward (issue #73). A function accessor can't be inverted to a writable
|
|
41
|
+
// key, so node creation with one isn't supportable — fail fast instead of
|
|
42
|
+
// returning a node that throws deeper in the tree.
|
|
43
|
+
const idKey = typeof idAccessor === "string" ? idAccessor : "id";
|
|
44
|
+
const childrenKey = typeof childrenAccessor === "string" ? childrenAccessor : "children";
|
|
45
|
+
|
|
38
46
|
const onCreate: CreateHandler<T> = ({ parentId, index, type }) => {
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
if (typeof idAccessor === "function") {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`React Arborist => initialData can't create nodes when idAccessor is a function: the generated id can't be written under a key the accessor reads. Use a string idAccessor, or the controlled \`data\` prop with your own onCreate.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (type === "internal" && typeof childrenAccessor === "function") {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`React Arborist => initialData can't create folder nodes when childrenAccessor is a function: the new children array can't be written under a key the accessor reads. Use a string childrenAccessor, or the controlled \`data\` prop with your own onCreate.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const data = { [idKey]: `simple-tree-id-${nextId++}`, name: "" } as any;
|
|
58
|
+
if (type === "internal") data[childrenKey] = [];
|
|
41
59
|
tree.create({ parentId, index, data });
|
|
42
60
|
setData(tree.data);
|
|
43
61
|
return data;
|
|
@@ -21,7 +21,10 @@ Use the data prop if you want to provide your own handlers.`,
|
|
|
21
21
|
*
|
|
22
22
|
* We will provide the real data and the handlers to update it.
|
|
23
23
|
* */
|
|
24
|
-
const [data, controller] = useSimpleTree<T>(props.initialData
|
|
24
|
+
const [data, controller] = useSimpleTree<T>(props.initialData, {
|
|
25
|
+
idAccessor: props.idAccessor,
|
|
26
|
+
childrenAccessor: props.childrenAccessor,
|
|
27
|
+
});
|
|
25
28
|
return { ...props, ...controller, data };
|
|
26
29
|
} else {
|
|
27
30
|
return props;
|
|
@@ -47,6 +47,52 @@ describe("tree.drop() fires onMove (#313)", () => {
|
|
|
47
47
|
});
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
describe("custom idAccessor is honored when methods receive raw data (#347)", () => {
|
|
51
|
+
const uuidData = [{ uuid: "a" }, { uuid: "b" }, { uuid: "c" }];
|
|
52
|
+
|
|
53
|
+
test("select(data) resolves the id through idAccessor", () => {
|
|
54
|
+
const onSelect = jest.fn();
|
|
55
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onSelect });
|
|
56
|
+
api.select(uuidData[1]);
|
|
57
|
+
expect(api.selectedIds.has("b")).toBe(true);
|
|
58
|
+
expect(api.selectedNodes.map((n) => n.id)).toEqual(["b"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("focus(data) resolves the id through idAccessor", () => {
|
|
62
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid" });
|
|
63
|
+
api.focus(uuidData[2]);
|
|
64
|
+
expect(api.focusedNode?.id).toBe("c");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("delete(data) passes the accessor-derived id to onDelete", () => {
|
|
68
|
+
const onDelete = jest.fn();
|
|
69
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onDelete });
|
|
70
|
+
api.delete(uuidData[0]);
|
|
71
|
+
expect(onDelete).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({ ids: ["a"] }),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("create() focuses the new node by its accessor-derived id", async () => {
|
|
77
|
+
// create() passes the raw row data returned by onCreate straight to
|
|
78
|
+
// focus/edit/select; before the fix these read `.id` and lost the node.
|
|
79
|
+
const onCreate = () => ({ uuid: "new" });
|
|
80
|
+
const api = setupApi({ data: uuidData, idAccessor: "uuid", onCreate });
|
|
81
|
+
await api.create();
|
|
82
|
+
expect(api.state.nodes.focus.id).toBe("new");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a function idAccessor is honored too", () => {
|
|
86
|
+
const fnData = [{ meta: { key: "x" } }, { meta: { key: "y" } }];
|
|
87
|
+
const api = setupApi({
|
|
88
|
+
data: fnData,
|
|
89
|
+
idAccessor: (d: any) => d.meta.key,
|
|
90
|
+
});
|
|
91
|
+
api.select(fnData[1]);
|
|
92
|
+
expect(api.selectedIds.has("y")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
50
96
|
test("rowHeight defaults to 24", () => {
|
|
51
97
|
const api = setupApi({});
|
|
52
98
|
expect(api.rowHeight).toBe(24);
|