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.
Files changed (35) hide show
  1. package/dist/main/data/simple-tree.d.ts +14 -8
  2. package/dist/main/data/simple-tree.js +34 -15
  3. package/dist/main/data/simple-tree.test.d.ts +1 -0
  4. package/dist/main/data/simple-tree.test.js +63 -0
  5. package/dist/main/hooks/use-simple-tree.d.ts +2 -1
  6. package/dist/main/hooks/use-simple-tree.js +19 -4
  7. package/dist/main/hooks/use-simple-tree.test.d.ts +1 -0
  8. package/dist/main/hooks/use-simple-tree.test.js +32 -0
  9. package/dist/main/hooks/use-validated-props.js +4 -1
  10. package/dist/main/interfaces/tree-api.d.ts +33 -20
  11. package/dist/main/interfaces/tree-api.js +48 -23
  12. package/dist/main/interfaces/tree-api.test.js +48 -0
  13. package/dist/main/types/handlers.d.ts +1 -1
  14. package/dist/module/data/simple-tree.d.ts +14 -8
  15. package/dist/module/data/simple-tree.js +34 -15
  16. package/dist/module/data/simple-tree.test.d.ts +1 -0
  17. package/dist/module/data/simple-tree.test.js +61 -0
  18. package/dist/module/hooks/use-simple-tree.d.ts +2 -1
  19. package/dist/module/hooks/use-simple-tree.js +19 -4
  20. package/dist/module/hooks/use-simple-tree.test.d.ts +1 -0
  21. package/dist/module/hooks/use-simple-tree.test.js +30 -0
  22. package/dist/module/hooks/use-validated-props.js +4 -1
  23. package/dist/module/interfaces/tree-api.d.ts +33 -20
  24. package/dist/module/interfaces/tree-api.js +48 -23
  25. package/dist/module/interfaces/tree-api.test.js +48 -0
  26. package/dist/module/types/handlers.d.ts +1 -1
  27. package/package.json +1 -1
  28. package/src/data/simple-tree.test.ts +68 -0
  29. package/src/data/simple-tree.ts +53 -16
  30. package/src/hooks/use-simple-tree.test.ts +39 -0
  31. package/src/hooks/use-simple-tree.ts +26 -8
  32. package/src/hooks/use-validated-props.ts +4 -1
  33. package/src/interfaces/tree-api.test.ts +46 -0
  34. package/src/interfaces/tree-api.ts +68 -41
  35. 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, identify, identifyNull } = utils;
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 = utils.identifyNull(id);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "3.10.3",
3
+ "version": "3.10.5",
4
4
  "keywords": [
5
5
  "arborist",
6
6
  "dnd",
@@ -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
+ });
@@ -1,9 +1,37 @@
1
- type SimpleData = { id: string; name: string; children?: SimpleData[] };
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 extends SimpleData> {
28
+ export class SimpleTree<T> {
4
29
  root: SimpleNode<T>;
5
- constructor(data: T[]) {
6
- this.root = createRoot<T>(data);
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 extends SimpleData>(data: T[]) {
52
- const root = new SimpleNode<T>({ id: "ROOT" } as T, null);
53
- root.children = data.map((d) => createNode(d as T, root));
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 extends SimpleData>(data: T, parent: SimpleNode<T>) {
58
- const node = new SimpleNode<T>(data, parent);
59
- if (data.children) node.children = data.children.map((d) => createNode<T>(d as T, node));
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 extends SimpleData> {
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.id;
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
- this.data.children = this.data.children ?? [];
86
- this.data.children.splice(index, 0, 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.children?.splice(index, 1);
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
- new SimpleTree<// @ts-ignore
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
- const data = { id: `simple-tree-id-${nextId++}`, name: "" } as any;
40
- if (type === "internal") data.children = [];
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);