react-arborist 3.10.4 → 3.10.6
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/interfaces/tree-api.d.ts +39 -20
- package/dist/main/interfaces/tree-api.js +75 -23
- package/dist/main/interfaces/tree-api.test.js +100 -0
- package/dist/main/types/handlers.d.ts +1 -1
- package/dist/module/interfaces/tree-api.d.ts +39 -20
- package/dist/module/interfaces/tree-api.js +75 -23
- package/dist/module/interfaces/tree-api.test.js +100 -0
- package/dist/module/types/handlers.d.ts +1 -1
- package/package.json +1 -1
- package/src/interfaces/tree-api.test.ts +106 -0
- package/src/interfaces/tree-api.ts +94 -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)
|
|
@@ -632,11 +657,38 @@ export class TreeApi {
|
|
|
632
657
|
if (index === undefined)
|
|
633
658
|
return;
|
|
634
659
|
(_a = this.list.current) === null || _a === void 0 ? void 0 : _a.scrollToItem(index, align);
|
|
660
|
+
/* react-window only scrolls vertically. A deeply nested node is
|
|
661
|
+
indented by level * indent and can sit past the right edge when rows
|
|
662
|
+
overflow horizontally, so bring it into view ourselves (#220). */
|
|
663
|
+
this.scrollToNodeHorizontally(this.get(id));
|
|
635
664
|
})
|
|
636
665
|
.catch(() => {
|
|
637
666
|
// Id: ${id} never appeared in the list.
|
|
638
667
|
});
|
|
639
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Horizontally scroll the list so the node's indented content is in view.
|
|
671
|
+
* A no-op when the list doesn't overflow horizontally (the common case), so
|
|
672
|
+
* it never disturbs scrolling for trees that fit their width.
|
|
673
|
+
*/
|
|
674
|
+
scrollToNodeHorizontally(node) {
|
|
675
|
+
const el = this.listEl.current;
|
|
676
|
+
if (!node || !el)
|
|
677
|
+
return;
|
|
678
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
679
|
+
if (maxScroll <= 0)
|
|
680
|
+
return; // nothing to scroll
|
|
681
|
+
const left = node.level * this.indent;
|
|
682
|
+
const viewLeft = el.scrollLeft;
|
|
683
|
+
const viewRight = el.scrollLeft + el.clientWidth;
|
|
684
|
+
/* The visible range is half-open [viewLeft, viewRight): a pixel at viewRight
|
|
685
|
+
is already clipped. Only move when the node's indentation falls outside
|
|
686
|
+
it, aligning its content start to the left edge so the label is revealed,
|
|
687
|
+
clamped to the list's scrollable range. */
|
|
688
|
+
if (left < viewLeft || left >= viewRight) {
|
|
689
|
+
el.scrollLeft = Math.max(0, Math.min(left, maxScroll));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
640
692
|
/* State Checks */
|
|
641
693
|
get isEditing() {
|
|
642
694
|
return this.state.nodes.edit.id !== null;
|
|
@@ -689,7 +741,7 @@ export class TreeApi {
|
|
|
689
741
|
return !utils.access(data, disabler);
|
|
690
742
|
}
|
|
691
743
|
isDragging(node) {
|
|
692
|
-
const id = identifyNull(node);
|
|
744
|
+
const id = this.identifyNull(node);
|
|
693
745
|
if (!id)
|
|
694
746
|
return false;
|
|
695
747
|
return this.state.nodes.drag.id === id;
|
|
@@ -701,7 +753,7 @@ export class TreeApi {
|
|
|
701
753
|
return this.matchFn(node);
|
|
702
754
|
}
|
|
703
755
|
willReceiveDrop(node) {
|
|
704
|
-
const id = identifyNull(node);
|
|
756
|
+
const id = this.identifyNull(node);
|
|
705
757
|
if (!id)
|
|
706
758
|
return false;
|
|
707
759
|
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);
|
|
@@ -117,3 +165,55 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
|
|
|
117
165
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
118
166
|
});
|
|
119
167
|
});
|
|
168
|
+
describe("scrollTo brings a deeply nested node into view horizontally (#220)", () => {
|
|
169
|
+
// A folder tree where "deep" sits at level 2 (indented 2 * 24 = 48px).
|
|
170
|
+
const nestedData = [{ id: "root", children: [{ id: "mid", children: [{ id: "deep" }] }] }];
|
|
171
|
+
// react-window's scrollToItem only scrolls vertically; the horizontal scroll
|
|
172
|
+
// happens on the outer list element, which we stub here.
|
|
173
|
+
function setupWithListEl(el) {
|
|
174
|
+
const store = createStore(rootReducer);
|
|
175
|
+
const list = { current: { scrollToItem: jest.fn() } };
|
|
176
|
+
const listEl = { current: el };
|
|
177
|
+
return new TreeApi(store, { data: nestedData }, list, listEl);
|
|
178
|
+
}
|
|
179
|
+
test("scrolls right when the node is past the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
180
|
+
const el = { scrollWidth: 500, clientWidth: 40, scrollLeft: 0 };
|
|
181
|
+
const api = setupWithListEl(el);
|
|
182
|
+
yield api.scrollTo("deep");
|
|
183
|
+
// Aligns the node's indentation start (level 2 * indent 24) to the left edge.
|
|
184
|
+
expect(el.scrollLeft).toBe(48);
|
|
185
|
+
}));
|
|
186
|
+
test("scrolls left when the node is past the left edge", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
187
|
+
const el = { scrollWidth: 500, clientWidth: 100, scrollLeft: 200 };
|
|
188
|
+
const api = setupWithListEl(el);
|
|
189
|
+
yield api.scrollTo("deep");
|
|
190
|
+
expect(el.scrollLeft).toBe(48);
|
|
191
|
+
}));
|
|
192
|
+
test("scrolls when the node's start sits exactly on the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
193
|
+
// viewRight === left (48): the visible range is half-open, so the content
|
|
194
|
+
// start is already clipped and must be scrolled into view.
|
|
195
|
+
const el = { scrollWidth: 500, clientWidth: 48, scrollLeft: 0 };
|
|
196
|
+
const api = setupWithListEl(el);
|
|
197
|
+
yield api.scrollTo("deep");
|
|
198
|
+
expect(el.scrollLeft).toBe(48);
|
|
199
|
+
}));
|
|
200
|
+
test("clamps the target to the maximum scrollable distance", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
201
|
+
// left (48) exceeds maxScroll (60 - 40 = 20), so scrollLeft is clamped.
|
|
202
|
+
const el = { scrollWidth: 60, clientWidth: 40, scrollLeft: 0 };
|
|
203
|
+
const api = setupWithListEl(el);
|
|
204
|
+
yield api.scrollTo("deep");
|
|
205
|
+
expect(el.scrollLeft).toBe(20);
|
|
206
|
+
}));
|
|
207
|
+
test("leaves scroll untouched when the node is already in view", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
208
|
+
const el = { scrollWidth: 500, clientWidth: 200, scrollLeft: 0 };
|
|
209
|
+
const api = setupWithListEl(el);
|
|
210
|
+
yield api.scrollTo("deep");
|
|
211
|
+
expect(el.scrollLeft).toBe(0);
|
|
212
|
+
}));
|
|
213
|
+
test("no-ops when the list does not overflow horizontally", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
214
|
+
const el = { scrollWidth: 100, clientWidth: 100, scrollLeft: 0 };
|
|
215
|
+
const api = setupWithListEl(el);
|
|
216
|
+
yield api.scrollTo("deep");
|
|
217
|
+
expect(el.scrollLeft).toBe(0);
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
@@ -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
|
@@ -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);
|
|
@@ -138,3 +184,63 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
|
|
|
138
184
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
139
185
|
});
|
|
140
186
|
});
|
|
187
|
+
|
|
188
|
+
describe("scrollTo brings a deeply nested node into view horizontally (#220)", () => {
|
|
189
|
+
// A folder tree where "deep" sits at level 2 (indented 2 * 24 = 48px).
|
|
190
|
+
const nestedData = [{ id: "root", children: [{ id: "mid", children: [{ id: "deep" }] }] }];
|
|
191
|
+
|
|
192
|
+
// react-window's scrollToItem only scrolls vertically; the horizontal scroll
|
|
193
|
+
// happens on the outer list element, which we stub here.
|
|
194
|
+
function setupWithListEl(el: Partial<HTMLDivElement>) {
|
|
195
|
+
const store = createStore(rootReducer);
|
|
196
|
+
const list = { current: { scrollToItem: jest.fn() } as any };
|
|
197
|
+
const listEl = { current: el as HTMLDivElement };
|
|
198
|
+
return new TreeApi(store, { data: nestedData }, list, listEl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("scrolls right when the node is past the right edge", async () => {
|
|
202
|
+
const el = { scrollWidth: 500, clientWidth: 40, scrollLeft: 0 };
|
|
203
|
+
const api = setupWithListEl(el);
|
|
204
|
+
await api.scrollTo("deep");
|
|
205
|
+
// Aligns the node's indentation start (level 2 * indent 24) to the left edge.
|
|
206
|
+
expect(el.scrollLeft).toBe(48);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("scrolls left when the node is past the left edge", async () => {
|
|
210
|
+
const el = { scrollWidth: 500, clientWidth: 100, scrollLeft: 200 };
|
|
211
|
+
const api = setupWithListEl(el);
|
|
212
|
+
await api.scrollTo("deep");
|
|
213
|
+
expect(el.scrollLeft).toBe(48);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("scrolls when the node's start sits exactly on the right edge", async () => {
|
|
217
|
+
// viewRight === left (48): the visible range is half-open, so the content
|
|
218
|
+
// start is already clipped and must be scrolled into view.
|
|
219
|
+
const el = { scrollWidth: 500, clientWidth: 48, scrollLeft: 0 };
|
|
220
|
+
const api = setupWithListEl(el);
|
|
221
|
+
await api.scrollTo("deep");
|
|
222
|
+
expect(el.scrollLeft).toBe(48);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("clamps the target to the maximum scrollable distance", async () => {
|
|
226
|
+
// left (48) exceeds maxScroll (60 - 40 = 20), so scrollLeft is clamped.
|
|
227
|
+
const el = { scrollWidth: 60, clientWidth: 40, scrollLeft: 0 };
|
|
228
|
+
const api = setupWithListEl(el);
|
|
229
|
+
await api.scrollTo("deep");
|
|
230
|
+
expect(el.scrollLeft).toBe(20);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("leaves scroll untouched when the node is already in view", async () => {
|
|
234
|
+
const el = { scrollWidth: 500, clientWidth: 200, scrollLeft: 0 };
|
|
235
|
+
const api = setupWithListEl(el);
|
|
236
|
+
await api.scrollTo("deep");
|
|
237
|
+
expect(el.scrollLeft).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("no-ops when the list does not overflow horizontally", async () => {
|
|
241
|
+
const el = { scrollWidth: 100, clientWidth: 100, scrollLeft: 0 };
|
|
242
|
+
const api = setupWithListEl(el);
|
|
243
|
+
await api.scrollTo("deep");
|
|
244
|
+
expect(el.scrollLeft).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
});
|