react-arborist 3.10.2 → 3.10.4
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/dnd/drag-hook.d.ts +1 -0
- package/dist/main/dnd/drag-hook.js +8 -1
- package/dist/main/dnd/drag-hook.test.js +14 -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/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/dnd/drag-hook.d.ts +1 -0
- package/dist/module/dnd/drag-hook.js +7 -1
- package/dist/module/dnd/drag-hook.test.js +15 -1
- 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/package.json +1 -1
- package/src/data/simple-tree.test.ts +68 -0
- package/src/data/simple-tree.ts +53 -16
- package/src/dnd/drag-hook.test.ts +19 -1
- package/src/dnd/drag-hook.ts +8 -1
- 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
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export type SimpleTreeOptions<T> = {
|
|
2
|
+
idAccessor?: string | ((d: T) => string);
|
|
3
|
+
childrenAccessor?: string | ((d: T) => readonly T[] | null | undefined);
|
|
4
|
+
};
|
|
5
|
+
type Accessors<T> = {
|
|
6
|
+
getId: (data: T) => string;
|
|
7
|
+
getChildren: (data: T) => readonly T[] | null | undefined;
|
|
8
|
+
childrenKey: string;
|
|
5
9
|
};
|
|
6
|
-
export declare class SimpleTree<T
|
|
10
|
+
export declare class SimpleTree<T> {
|
|
7
11
|
root: SimpleNode<T>;
|
|
8
|
-
|
|
12
|
+
private accessors;
|
|
13
|
+
constructor(data: T[], options?: SimpleTreeOptions<T>);
|
|
9
14
|
get data(): T[];
|
|
10
15
|
create(args: {
|
|
11
16
|
parentId: string | null;
|
|
@@ -26,12 +31,13 @@ export declare class SimpleTree<T extends SimpleData> {
|
|
|
26
31
|
}): void;
|
|
27
32
|
find(id: string, node?: SimpleNode<T>): SimpleNode<T> | null;
|
|
28
33
|
}
|
|
29
|
-
declare class SimpleNode<T
|
|
34
|
+
declare class SimpleNode<T> {
|
|
30
35
|
data: T;
|
|
31
36
|
parent: SimpleNode<T> | null;
|
|
37
|
+
private accessors;
|
|
32
38
|
id: string;
|
|
33
39
|
children?: SimpleNode<T>[];
|
|
34
|
-
constructor(data: T, parent: SimpleNode<T> | null);
|
|
40
|
+
constructor(data: T, parent: SimpleNode<T> | null, accessors: Accessors<T>, id?: string);
|
|
35
41
|
hasParent(): this is this & {
|
|
36
42
|
parent: SimpleNode<T>;
|
|
37
43
|
};
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SimpleTree = void 0;
|
|
4
|
+
function resolveAccessors(options = {}) {
|
|
5
|
+
var _a, _b;
|
|
6
|
+
const id = (_a = options.idAccessor) !== null && _a !== void 0 ? _a : "id";
|
|
7
|
+
const children = (_b = options.childrenAccessor) !== null && _b !== void 0 ? _b : "children";
|
|
8
|
+
return {
|
|
9
|
+
getId: typeof id === "function" ? id : (data) => data[id],
|
|
10
|
+
getChildren: typeof children === "function" ? children : (data) => data[children],
|
|
11
|
+
childrenKey: typeof children === "string" ? children : "children",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
4
14
|
class SimpleTree {
|
|
5
|
-
constructor(data) {
|
|
6
|
-
this.
|
|
15
|
+
constructor(data, options = {}) {
|
|
16
|
+
this.accessors = resolveAccessors(options);
|
|
17
|
+
this.root = createRoot(data, this.accessors);
|
|
7
18
|
}
|
|
8
19
|
get data() {
|
|
9
20
|
var _a, _b;
|
|
@@ -50,22 +61,27 @@ class SimpleTree {
|
|
|
50
61
|
}
|
|
51
62
|
}
|
|
52
63
|
exports.SimpleTree = SimpleTree;
|
|
53
|
-
function createRoot(data) {
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
function createRoot(data, accessors) {
|
|
65
|
+
// The synthetic root has no real data, so it gets an explicit id rather than
|
|
66
|
+
// running the user's accessor on `{}` — a function accessor that reaches into
|
|
67
|
+
// the data (e.g. `d => d.meta.id`) would otherwise throw during construction.
|
|
68
|
+
const root = new SimpleNode({}, null, accessors, "ROOT");
|
|
69
|
+
root.children = data.map((d) => createNode(d, root, accessors));
|
|
56
70
|
return root;
|
|
57
71
|
}
|
|
58
|
-
function createNode(data, parent) {
|
|
59
|
-
const node = new SimpleNode(data, parent);
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
function createNode(data, parent, accessors) {
|
|
73
|
+
const node = new SimpleNode(data, parent, accessors);
|
|
74
|
+
const children = accessors.getChildren(data);
|
|
75
|
+
if (children)
|
|
76
|
+
node.children = children.map((d) => createNode(d, node, accessors));
|
|
62
77
|
return node;
|
|
63
78
|
}
|
|
64
79
|
class SimpleNode {
|
|
65
|
-
constructor(data, parent) {
|
|
80
|
+
constructor(data, parent, accessors, id) {
|
|
66
81
|
this.data = data;
|
|
67
82
|
this.parent = parent;
|
|
68
|
-
this.
|
|
83
|
+
this.accessors = accessors;
|
|
84
|
+
this.id = id !== null && id !== void 0 ? id : accessors.getId(data);
|
|
69
85
|
}
|
|
70
86
|
hasParent() {
|
|
71
87
|
return !!this.parent;
|
|
@@ -75,16 +91,19 @@ class SimpleNode {
|
|
|
75
91
|
}
|
|
76
92
|
addChild(data, index) {
|
|
77
93
|
var _a, _b;
|
|
78
|
-
const node = createNode(data, this);
|
|
94
|
+
const node = createNode(data, this, this.accessors);
|
|
79
95
|
this.children = (_a = this.children) !== null && _a !== void 0 ? _a : [];
|
|
80
96
|
this.children.splice(index, 0, node);
|
|
81
|
-
|
|
82
|
-
this.data
|
|
97
|
+
const key = this.accessors.childrenKey;
|
|
98
|
+
const raw = this.data;
|
|
99
|
+
raw[key] = (_b = raw[key]) !== null && _b !== void 0 ? _b : [];
|
|
100
|
+
raw[key].splice(index, 0, data);
|
|
83
101
|
}
|
|
84
102
|
removeChild(index) {
|
|
85
103
|
var _a, _b;
|
|
86
104
|
(_a = this.children) === null || _a === void 0 ? void 0 : _a.splice(index, 1);
|
|
87
|
-
|
|
105
|
+
const raw = this.data;
|
|
106
|
+
(_b = raw[this.accessors.childrenKey]) === null || _b === void 0 ? void 0 : _b.splice(index, 1);
|
|
88
107
|
}
|
|
89
108
|
update(changes) {
|
|
90
109
|
if (this.hasParent()) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const simple_tree_1 = require("./simple-tree");
|
|
4
|
+
describe("SimpleTree with default accessors", () => {
|
|
5
|
+
const data = () => [
|
|
6
|
+
{ id: "1", name: "a", children: [{ id: "1a", name: "a-child" }] },
|
|
7
|
+
{ id: "2", name: "b" },
|
|
8
|
+
];
|
|
9
|
+
test("finds nodes by id, including nested ones", () => {
|
|
10
|
+
var _a, _b;
|
|
11
|
+
const tree = new simple_tree_1.SimpleTree(data());
|
|
12
|
+
expect((_a = tree.find("2")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("b");
|
|
13
|
+
expect((_b = tree.find("1a")) === null || _b === void 0 ? void 0 : _b.data.name).toBe("a-child");
|
|
14
|
+
});
|
|
15
|
+
test("moves a node into a folder", () => {
|
|
16
|
+
const tree = new simple_tree_1.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
|
+
describe("SimpleTree honors custom accessors (issue #73, #170)", () => {
|
|
23
|
+
// Custom keys: `uuid` for the id, `elements` for the children.
|
|
24
|
+
const data = () => [
|
|
25
|
+
{ uuid: "1", name: "a", elements: [{ uuid: "1a", name: "a-child" }] },
|
|
26
|
+
{ uuid: "2", name: "b" },
|
|
27
|
+
];
|
|
28
|
+
function tree() {
|
|
29
|
+
return new simple_tree_1.SimpleTree(data(), { idAccessor: "uuid", childrenAccessor: "elements" });
|
|
30
|
+
}
|
|
31
|
+
test("finds nodes by the custom id key, including nested ones", () => {
|
|
32
|
+
var _a, _b;
|
|
33
|
+
const t = tree();
|
|
34
|
+
expect((_a = t.find("2")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("b");
|
|
35
|
+
expect((_b = t.find("1a")) === null || _b === void 0 ? void 0 : _b.data.name).toBe("a-child"); // read through `elements`
|
|
36
|
+
});
|
|
37
|
+
test("reorders a node, writing children back under the custom key (#170)", () => {
|
|
38
|
+
const t = tree();
|
|
39
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
40
|
+
expect(t.data).toHaveLength(1);
|
|
41
|
+
expect(t.data[0].elements.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
42
|
+
});
|
|
43
|
+
test("moving a node with children into a childless node keeps its children (#73)", () => {
|
|
44
|
+
const t = tree();
|
|
45
|
+
// Put node "1" (which has children) inside node "2" (which has none).
|
|
46
|
+
t.move({ id: "1", parentId: "2", index: 0 });
|
|
47
|
+
expect(t.data.map((n) => n.uuid)).toEqual(["2"]);
|
|
48
|
+
const moved = t.find("1");
|
|
49
|
+
expect(moved === null || moved === void 0 ? void 0 : moved.data.elements.map((c) => c.uuid)).toEqual(["1a"]);
|
|
50
|
+
});
|
|
51
|
+
test("supports a function idAccessor", () => {
|
|
52
|
+
var _a;
|
|
53
|
+
const t = new simple_tree_1.SimpleTree(data(), { idAccessor: (d) => d.uuid, childrenAccessor: "elements" });
|
|
54
|
+
expect((_a = t.find("1a")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("a-child");
|
|
55
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
56
|
+
expect(t.data[0].elements.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
57
|
+
});
|
|
58
|
+
test("a function idAccessor that reaches into the data doesn't throw on construction", () => {
|
|
59
|
+
const nested = [{ meta: { id: "x" }, name: "x" }];
|
|
60
|
+
// The synthetic root must not run this accessor on its empty data.
|
|
61
|
+
expect(() => new simple_tree_1.SimpleTree(nested, { idAccessor: (d) => d.meta.id })).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -2,4 +2,5 @@ import { ConnectDragSource } from "react-dnd";
|
|
|
2
2
|
import { NodeApi } from "../interfaces/node-api";
|
|
3
3
|
import { TreeProps } from "../types/tree-props";
|
|
4
4
|
export declare function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: NodeApi<T>): string;
|
|
5
|
+
export declare function canDragNode<T>(node: NodeApi<T>): boolean;
|
|
5
6
|
export declare function useDragHook<T>(node: NodeApi<T>): ConnectDragSource;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.dragTypeForNode = dragTypeForNode;
|
|
4
|
+
exports.canDragNode = canDragNode;
|
|
4
5
|
exports.useDragHook = useDragHook;
|
|
5
6
|
const react_1 = require("react");
|
|
6
7
|
const react_dnd_1 = require("react-dnd");
|
|
@@ -14,11 +15,17 @@ function dragTypeForNode(dragType, node) {
|
|
|
14
15
|
return dragType(node);
|
|
15
16
|
return dragType !== null && dragType !== void 0 ? dragType : "NODE";
|
|
16
17
|
}
|
|
18
|
+
/* A node can start a drag only when it's draggable and not currently being
|
|
19
|
+
renamed. Without the editing guard, dragging inside the rename input would
|
|
20
|
+
pick the row up and move it (issue #195). */
|
|
21
|
+
function canDragNode(node) {
|
|
22
|
+
return node.isDraggable && !node.isEditing;
|
|
23
|
+
}
|
|
17
24
|
function useDragHook(node) {
|
|
18
25
|
const tree = (0, context_1.useTreeApi)();
|
|
19
26
|
const ids = tree.selectedIds;
|
|
20
27
|
const [_, ref, preview] = (0, react_dnd_1.useDrag)(() => ({
|
|
21
|
-
canDrag: () => node
|
|
28
|
+
canDrag: () => canDragNode(node),
|
|
22
29
|
type: dragTypeForNode(tree.props.dragType, node),
|
|
23
30
|
item: () => {
|
|
24
31
|
// This is fired once at the beginning of a drag operation
|
|
@@ -17,3 +17,17 @@ test("resolves a per-node dragType function against the node", () => {
|
|
|
17
17
|
expect((0, drag_hook_1.dragTypeForNode)(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
18
18
|
expect((0, drag_hook_1.dragTypeForNode)(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
19
19
|
});
|
|
20
|
+
/* canDragNode only reads the isDraggable/isEditing flags, so a minimal stub
|
|
21
|
+
stands in for a real NodeApi. */
|
|
22
|
+
function draggableNode(flags) {
|
|
23
|
+
return flags;
|
|
24
|
+
}
|
|
25
|
+
test("a draggable node that isn't being edited can drag", () => {
|
|
26
|
+
expect((0, drag_hook_1.canDragNode)(draggableNode({ isDraggable: true, isEditing: false }))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
test("a non-draggable node can't drag", () => {
|
|
29
|
+
expect((0, drag_hook_1.canDragNode)(draggableNode({ isDraggable: false, isEditing: false }))).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
test("a node being renamed can't drag, even when draggable (#195)", () => {
|
|
32
|
+
expect((0, drag_hook_1.canDragNode)(draggableNode({ isDraggable: true, isEditing: true }))).toBe(false);
|
|
33
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { SimpleTreeOptions } from "../data/simple-tree";
|
|
1
2
|
import { CreateHandler, DeleteHandler, MoveHandler, RenameHandler } from "../types/handlers";
|
|
2
3
|
export type SimpleTreeData = {
|
|
3
4
|
id: string;
|
|
4
5
|
name: string;
|
|
5
6
|
children?: SimpleTreeData[];
|
|
6
7
|
};
|
|
7
|
-
export declare function useSimpleTree<T>(initialData: readonly T[]): readonly [readonly T[], {
|
|
8
|
+
export declare function useSimpleTree<T>(initialData: readonly T[], options?: SimpleTreeOptions<T>): readonly [readonly T[], {
|
|
8
9
|
onMove: MoveHandler<T>;
|
|
9
10
|
onRename: RenameHandler<T>;
|
|
10
11
|
onCreate: CreateHandler<T>;
|
|
@@ -4,9 +4,11 @@ exports.useSimpleTree = useSimpleTree;
|
|
|
4
4
|
const react_1 = require("react");
|
|
5
5
|
const simple_tree_1 = require("../data/simple-tree");
|
|
6
6
|
let nextId = 0;
|
|
7
|
-
function useSimpleTree(initialData) {
|
|
7
|
+
function useSimpleTree(initialData, options = {}) {
|
|
8
8
|
const [data, setData] = (0, react_1.useState)(initialData);
|
|
9
|
-
const
|
|
9
|
+
const idAccessor = options.idAccessor;
|
|
10
|
+
const childrenAccessor = options.childrenAccessor;
|
|
11
|
+
const tree = (0, react_1.useMemo)(() => new simple_tree_1.SimpleTree(data, { idAccessor, childrenAccessor }), [data, idAccessor, childrenAccessor]);
|
|
10
12
|
const onMove = (args) => {
|
|
11
13
|
for (const id of args.dragIds) {
|
|
12
14
|
tree.move({ id, parentId: args.parentId, index: args.index });
|
|
@@ -17,10 +19,23 @@ function useSimpleTree(initialData) {
|
|
|
17
19
|
tree.update({ id, changes: { name } });
|
|
18
20
|
setData(tree.data);
|
|
19
21
|
};
|
|
22
|
+
// New nodes must carry their id/children under the same keys the accessors
|
|
23
|
+
// read, or the controller (and the tree's own accessId) can't find them
|
|
24
|
+
// afterward (issue #73). A function accessor can't be inverted to a writable
|
|
25
|
+
// key, so node creation with one isn't supportable — fail fast instead of
|
|
26
|
+
// returning a node that throws deeper in the tree.
|
|
27
|
+
const idKey = typeof idAccessor === "string" ? idAccessor : "id";
|
|
28
|
+
const childrenKey = typeof childrenAccessor === "string" ? childrenAccessor : "children";
|
|
20
29
|
const onCreate = ({ parentId, index, type }) => {
|
|
21
|
-
|
|
30
|
+
if (typeof idAccessor === "function") {
|
|
31
|
+
throw new Error(`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.`);
|
|
32
|
+
}
|
|
33
|
+
if (type === "internal" && typeof childrenAccessor === "function") {
|
|
34
|
+
throw new Error(`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.`);
|
|
35
|
+
}
|
|
36
|
+
const data = { [idKey]: `simple-tree-id-${nextId++}`, name: "" };
|
|
22
37
|
if (type === "internal")
|
|
23
|
-
data
|
|
38
|
+
data[childrenKey] = [];
|
|
24
39
|
tree.create({ parentId, index, data });
|
|
25
40
|
setData(tree.data);
|
|
26
41
|
return data;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const react_1 = require("@testing-library/react");
|
|
4
|
+
const use_simple_tree_1 = require("./use-simple-tree");
|
|
5
|
+
/* onCreate has to write a new node's id (and a folder's children) under a key
|
|
6
|
+
the accessors will read back. A function accessor can't be inverted to a key,
|
|
7
|
+
so creation with one must fail fast rather than return an unusable node
|
|
8
|
+
(issue #73 review follow-up). */
|
|
9
|
+
describe("useSimpleTree onCreate guards function accessors", () => {
|
|
10
|
+
function controllerFor(data, options) {
|
|
11
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_simple_tree_1.useSimpleTree)(data, options));
|
|
12
|
+
return result.current[1];
|
|
13
|
+
}
|
|
14
|
+
const create = { parentId: null, parentNode: null, index: 0 };
|
|
15
|
+
test("throws when idAccessor is a function", () => {
|
|
16
|
+
const controller = controllerFor([{ uuid: "1", name: "a" }], { idAccessor: (d) => d.uuid });
|
|
17
|
+
expect(() => controller.onCreate(Object.assign(Object.assign({}, create), { type: "leaf" }))).toThrow(/idAccessor is a function/);
|
|
18
|
+
});
|
|
19
|
+
test("throws when creating a folder with a function childrenAccessor", () => {
|
|
20
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
21
|
+
childrenAccessor: (d) => d.kids,
|
|
22
|
+
});
|
|
23
|
+
expect(() => controller.onCreate(Object.assign(Object.assign({}, create), { type: "internal" }))).toThrow(/childrenAccessor is a function/);
|
|
24
|
+
});
|
|
25
|
+
test("a leaf can still be created when only childrenAccessor is a function", () => {
|
|
26
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
27
|
+
childrenAccessor: (d) => d.kids,
|
|
28
|
+
});
|
|
29
|
+
// onCreate calls setData, so run it inside act to keep the suite warning-clean.
|
|
30
|
+
expect(() => (0, react_1.act)(() => void controller.onCreate(Object.assign(Object.assign({}, create), { type: "leaf" })))).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -18,7 +18,10 @@ Use the data prop if you want to provide your own handlers.`);
|
|
|
18
18
|
*
|
|
19
19
|
* We will provide the real data and the handlers to update it.
|
|
20
20
|
* */
|
|
21
|
-
const [data, controller] = (0, use_simple_tree_1.useSimpleTree)(props.initialData
|
|
21
|
+
const [data, controller] = (0, use_simple_tree_1.useSimpleTree)(props.initialData, {
|
|
22
|
+
idAccessor: props.idAccessor,
|
|
23
|
+
childrenAccessor: props.childrenAccessor,
|
|
24
|
+
});
|
|
22
25
|
return Object.assign(Object.assign(Object.assign({}, props), controller), { data });
|
|
23
26
|
}
|
|
24
27
|
else {
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export type SimpleTreeOptions<T> = {
|
|
2
|
+
idAccessor?: string | ((d: T) => string);
|
|
3
|
+
childrenAccessor?: string | ((d: T) => readonly T[] | null | undefined);
|
|
4
|
+
};
|
|
5
|
+
type Accessors<T> = {
|
|
6
|
+
getId: (data: T) => string;
|
|
7
|
+
getChildren: (data: T) => readonly T[] | null | undefined;
|
|
8
|
+
childrenKey: string;
|
|
5
9
|
};
|
|
6
|
-
export declare class SimpleTree<T
|
|
10
|
+
export declare class SimpleTree<T> {
|
|
7
11
|
root: SimpleNode<T>;
|
|
8
|
-
|
|
12
|
+
private accessors;
|
|
13
|
+
constructor(data: T[], options?: SimpleTreeOptions<T>);
|
|
9
14
|
get data(): T[];
|
|
10
15
|
create(args: {
|
|
11
16
|
parentId: string | null;
|
|
@@ -26,12 +31,13 @@ export declare class SimpleTree<T extends SimpleData> {
|
|
|
26
31
|
}): void;
|
|
27
32
|
find(id: string, node?: SimpleNode<T>): SimpleNode<T> | null;
|
|
28
33
|
}
|
|
29
|
-
declare class SimpleNode<T
|
|
34
|
+
declare class SimpleNode<T> {
|
|
30
35
|
data: T;
|
|
31
36
|
parent: SimpleNode<T> | null;
|
|
37
|
+
private accessors;
|
|
32
38
|
id: string;
|
|
33
39
|
children?: SimpleNode<T>[];
|
|
34
|
-
constructor(data: T, parent: SimpleNode<T> | null);
|
|
40
|
+
constructor(data: T, parent: SimpleNode<T> | null, accessors: Accessors<T>, id?: string);
|
|
35
41
|
hasParent(): this is this & {
|
|
36
42
|
parent: SimpleNode<T>;
|
|
37
43
|
};
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
function resolveAccessors(options = {}) {
|
|
2
|
+
var _a, _b;
|
|
3
|
+
const id = (_a = options.idAccessor) !== null && _a !== void 0 ? _a : "id";
|
|
4
|
+
const children = (_b = options.childrenAccessor) !== null && _b !== void 0 ? _b : "children";
|
|
5
|
+
return {
|
|
6
|
+
getId: typeof id === "function" ? id : (data) => data[id],
|
|
7
|
+
getChildren: typeof children === "function" ? children : (data) => data[children],
|
|
8
|
+
childrenKey: typeof children === "string" ? children : "children",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
1
11
|
export class SimpleTree {
|
|
2
|
-
constructor(data) {
|
|
3
|
-
this.
|
|
12
|
+
constructor(data, options = {}) {
|
|
13
|
+
this.accessors = resolveAccessors(options);
|
|
14
|
+
this.root = createRoot(data, this.accessors);
|
|
4
15
|
}
|
|
5
16
|
get data() {
|
|
6
17
|
var _a, _b;
|
|
@@ -46,22 +57,27 @@ export class SimpleTree {
|
|
|
46
57
|
return null;
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
|
-
function createRoot(data) {
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
function createRoot(data, accessors) {
|
|
61
|
+
// The synthetic root has no real data, so it gets an explicit id rather than
|
|
62
|
+
// running the user's accessor on `{}` — a function accessor that reaches into
|
|
63
|
+
// the data (e.g. `d => d.meta.id`) would otherwise throw during construction.
|
|
64
|
+
const root = new SimpleNode({}, null, accessors, "ROOT");
|
|
65
|
+
root.children = data.map((d) => createNode(d, root, accessors));
|
|
52
66
|
return root;
|
|
53
67
|
}
|
|
54
|
-
function createNode(data, parent) {
|
|
55
|
-
const node = new SimpleNode(data, parent);
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
function createNode(data, parent, accessors) {
|
|
69
|
+
const node = new SimpleNode(data, parent, accessors);
|
|
70
|
+
const children = accessors.getChildren(data);
|
|
71
|
+
if (children)
|
|
72
|
+
node.children = children.map((d) => createNode(d, node, accessors));
|
|
58
73
|
return node;
|
|
59
74
|
}
|
|
60
75
|
class SimpleNode {
|
|
61
|
-
constructor(data, parent) {
|
|
76
|
+
constructor(data, parent, accessors, id) {
|
|
62
77
|
this.data = data;
|
|
63
78
|
this.parent = parent;
|
|
64
|
-
this.
|
|
79
|
+
this.accessors = accessors;
|
|
80
|
+
this.id = id !== null && id !== void 0 ? id : accessors.getId(data);
|
|
65
81
|
}
|
|
66
82
|
hasParent() {
|
|
67
83
|
return !!this.parent;
|
|
@@ -71,16 +87,19 @@ class SimpleNode {
|
|
|
71
87
|
}
|
|
72
88
|
addChild(data, index) {
|
|
73
89
|
var _a, _b;
|
|
74
|
-
const node = createNode(data, this);
|
|
90
|
+
const node = createNode(data, this, this.accessors);
|
|
75
91
|
this.children = (_a = this.children) !== null && _a !== void 0 ? _a : [];
|
|
76
92
|
this.children.splice(index, 0, node);
|
|
77
|
-
|
|
78
|
-
this.data
|
|
93
|
+
const key = this.accessors.childrenKey;
|
|
94
|
+
const raw = this.data;
|
|
95
|
+
raw[key] = (_b = raw[key]) !== null && _b !== void 0 ? _b : [];
|
|
96
|
+
raw[key].splice(index, 0, data);
|
|
79
97
|
}
|
|
80
98
|
removeChild(index) {
|
|
81
99
|
var _a, _b;
|
|
82
100
|
(_a = this.children) === null || _a === void 0 ? void 0 : _a.splice(index, 1);
|
|
83
|
-
|
|
101
|
+
const raw = this.data;
|
|
102
|
+
(_b = raw[this.accessors.childrenKey]) === null || _b === void 0 ? void 0 : _b.splice(index, 1);
|
|
84
103
|
}
|
|
85
104
|
update(changes) {
|
|
86
105
|
if (this.hasParent()) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SimpleTree } from "./simple-tree";
|
|
2
|
+
describe("SimpleTree with default accessors", () => {
|
|
3
|
+
const data = () => [
|
|
4
|
+
{ id: "1", name: "a", children: [{ id: "1a", name: "a-child" }] },
|
|
5
|
+
{ id: "2", name: "b" },
|
|
6
|
+
];
|
|
7
|
+
test("finds nodes by id, including nested ones", () => {
|
|
8
|
+
var _a, _b;
|
|
9
|
+
const tree = new SimpleTree(data());
|
|
10
|
+
expect((_a = tree.find("2")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("b");
|
|
11
|
+
expect((_b = tree.find("1a")) === null || _b === void 0 ? void 0 : _b.data.name).toBe("a-child");
|
|
12
|
+
});
|
|
13
|
+
test("moves a node into a folder", () => {
|
|
14
|
+
const tree = new SimpleTree(data());
|
|
15
|
+
tree.move({ id: "2", parentId: "1", index: 1 });
|
|
16
|
+
expect(tree.data[0].children.map((c) => c.id)).toEqual(["1a", "2"]);
|
|
17
|
+
expect(tree.data).toHaveLength(1);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("SimpleTree honors custom accessors (issue #73, #170)", () => {
|
|
21
|
+
// Custom keys: `uuid` for the id, `elements` for the children.
|
|
22
|
+
const data = () => [
|
|
23
|
+
{ uuid: "1", name: "a", elements: [{ uuid: "1a", name: "a-child" }] },
|
|
24
|
+
{ uuid: "2", name: "b" },
|
|
25
|
+
];
|
|
26
|
+
function tree() {
|
|
27
|
+
return new SimpleTree(data(), { idAccessor: "uuid", childrenAccessor: "elements" });
|
|
28
|
+
}
|
|
29
|
+
test("finds nodes by the custom id key, including nested ones", () => {
|
|
30
|
+
var _a, _b;
|
|
31
|
+
const t = tree();
|
|
32
|
+
expect((_a = t.find("2")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("b");
|
|
33
|
+
expect((_b = t.find("1a")) === null || _b === void 0 ? void 0 : _b.data.name).toBe("a-child"); // read through `elements`
|
|
34
|
+
});
|
|
35
|
+
test("reorders a node, writing children back under the custom key (#170)", () => {
|
|
36
|
+
const t = tree();
|
|
37
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
38
|
+
expect(t.data).toHaveLength(1);
|
|
39
|
+
expect(t.data[0].elements.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
40
|
+
});
|
|
41
|
+
test("moving a node with children into a childless node keeps its children (#73)", () => {
|
|
42
|
+
const t = tree();
|
|
43
|
+
// Put node "1" (which has children) inside node "2" (which has none).
|
|
44
|
+
t.move({ id: "1", parentId: "2", index: 0 });
|
|
45
|
+
expect(t.data.map((n) => n.uuid)).toEqual(["2"]);
|
|
46
|
+
const moved = t.find("1");
|
|
47
|
+
expect(moved === null || moved === void 0 ? void 0 : moved.data.elements.map((c) => c.uuid)).toEqual(["1a"]);
|
|
48
|
+
});
|
|
49
|
+
test("supports a function idAccessor", () => {
|
|
50
|
+
var _a;
|
|
51
|
+
const t = new SimpleTree(data(), { idAccessor: (d) => d.uuid, childrenAccessor: "elements" });
|
|
52
|
+
expect((_a = t.find("1a")) === null || _a === void 0 ? void 0 : _a.data.name).toBe("a-child");
|
|
53
|
+
t.move({ id: "2", parentId: "1", index: 1 });
|
|
54
|
+
expect(t.data[0].elements.map((c) => c.uuid)).toEqual(["1a", "2"]);
|
|
55
|
+
});
|
|
56
|
+
test("a function idAccessor that reaches into the data doesn't throw on construction", () => {
|
|
57
|
+
const nested = [{ meta: { id: "x" }, name: "x" }];
|
|
58
|
+
// The synthetic root must not run this accessor on its empty data.
|
|
59
|
+
expect(() => new SimpleTree(nested, { idAccessor: (d) => d.meta.id })).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -2,4 +2,5 @@ import { ConnectDragSource } from "react-dnd";
|
|
|
2
2
|
import { NodeApi } from "../interfaces/node-api";
|
|
3
3
|
import { TreeProps } from "../types/tree-props";
|
|
4
4
|
export declare function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: NodeApi<T>): string;
|
|
5
|
+
export declare function canDragNode<T>(node: NodeApi<T>): boolean;
|
|
5
6
|
export declare function useDragHook<T>(node: NodeApi<T>): ConnectDragSource;
|
|
@@ -10,11 +10,17 @@ export function dragTypeForNode(dragType, node) {
|
|
|
10
10
|
return dragType(node);
|
|
11
11
|
return dragType !== null && dragType !== void 0 ? dragType : "NODE";
|
|
12
12
|
}
|
|
13
|
+
/* A node can start a drag only when it's draggable and not currently being
|
|
14
|
+
renamed. Without the editing guard, dragging inside the rename input would
|
|
15
|
+
pick the row up and move it (issue #195). */
|
|
16
|
+
export function canDragNode(node) {
|
|
17
|
+
return node.isDraggable && !node.isEditing;
|
|
18
|
+
}
|
|
13
19
|
export function useDragHook(node) {
|
|
14
20
|
const tree = useTreeApi();
|
|
15
21
|
const ids = tree.selectedIds;
|
|
16
22
|
const [_, ref, preview] = useDrag(() => ({
|
|
17
|
-
canDrag: () => node
|
|
23
|
+
canDrag: () => canDragNode(node),
|
|
18
24
|
type: dragTypeForNode(tree.props.dragType, node),
|
|
19
25
|
item: () => {
|
|
20
26
|
// This is fired once at the beginning of a drag operation
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dragTypeForNode } from "./drag-hook";
|
|
1
|
+
import { canDragNode, dragTypeForNode } from "./drag-hook";
|
|
2
2
|
/* dragTypeForNode only reads node.data when dragType is a function, so a
|
|
3
3
|
minimal stub stands in for a real NodeApi. */
|
|
4
4
|
function nodeWith(data) {
|
|
@@ -15,3 +15,17 @@ test("resolves a per-node dragType function against the node", () => {
|
|
|
15
15
|
expect(dragTypeForNode(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
16
16
|
expect(dragTypeForNode(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
17
17
|
});
|
|
18
|
+
/* canDragNode only reads the isDraggable/isEditing flags, so a minimal stub
|
|
19
|
+
stands in for a real NodeApi. */
|
|
20
|
+
function draggableNode(flags) {
|
|
21
|
+
return flags;
|
|
22
|
+
}
|
|
23
|
+
test("a draggable node that isn't being edited can drag", () => {
|
|
24
|
+
expect(canDragNode(draggableNode({ isDraggable: true, isEditing: false }))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
test("a non-draggable node can't drag", () => {
|
|
27
|
+
expect(canDragNode(draggableNode({ isDraggable: false, isEditing: false }))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test("a node being renamed can't drag, even when draggable (#195)", () => {
|
|
30
|
+
expect(canDragNode(draggableNode({ isDraggable: true, isEditing: true }))).toBe(false);
|
|
31
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { SimpleTreeOptions } from "../data/simple-tree";
|
|
1
2
|
import { CreateHandler, DeleteHandler, MoveHandler, RenameHandler } from "../types/handlers";
|
|
2
3
|
export type SimpleTreeData = {
|
|
3
4
|
id: string;
|
|
4
5
|
name: string;
|
|
5
6
|
children?: SimpleTreeData[];
|
|
6
7
|
};
|
|
7
|
-
export declare function useSimpleTree<T>(initialData: readonly T[]): readonly [readonly T[], {
|
|
8
|
+
export declare function useSimpleTree<T>(initialData: readonly T[], options?: SimpleTreeOptions<T>): readonly [readonly T[], {
|
|
8
9
|
onMove: MoveHandler<T>;
|
|
9
10
|
onRename: RenameHandler<T>;
|
|
10
11
|
onCreate: CreateHandler<T>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
2
|
import { SimpleTree } from "../data/simple-tree";
|
|
3
3
|
let nextId = 0;
|
|
4
|
-
export function useSimpleTree(initialData) {
|
|
4
|
+
export function useSimpleTree(initialData, options = {}) {
|
|
5
5
|
const [data, setData] = useState(initialData);
|
|
6
|
-
const
|
|
6
|
+
const idAccessor = options.idAccessor;
|
|
7
|
+
const childrenAccessor = options.childrenAccessor;
|
|
8
|
+
const tree = useMemo(() => new SimpleTree(data, { idAccessor, childrenAccessor }), [data, idAccessor, childrenAccessor]);
|
|
7
9
|
const onMove = (args) => {
|
|
8
10
|
for (const id of args.dragIds) {
|
|
9
11
|
tree.move({ id, parentId: args.parentId, index: args.index });
|
|
@@ -14,10 +16,23 @@ export function useSimpleTree(initialData) {
|
|
|
14
16
|
tree.update({ id, changes: { name } });
|
|
15
17
|
setData(tree.data);
|
|
16
18
|
};
|
|
19
|
+
// New nodes must carry their id/children under the same keys the accessors
|
|
20
|
+
// read, or the controller (and the tree's own accessId) can't find them
|
|
21
|
+
// afterward (issue #73). A function accessor can't be inverted to a writable
|
|
22
|
+
// key, so node creation with one isn't supportable — fail fast instead of
|
|
23
|
+
// returning a node that throws deeper in the tree.
|
|
24
|
+
const idKey = typeof idAccessor === "string" ? idAccessor : "id";
|
|
25
|
+
const childrenKey = typeof childrenAccessor === "string" ? childrenAccessor : "children";
|
|
17
26
|
const onCreate = ({ parentId, index, type }) => {
|
|
18
|
-
|
|
27
|
+
if (typeof idAccessor === "function") {
|
|
28
|
+
throw new Error(`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.`);
|
|
29
|
+
}
|
|
30
|
+
if (type === "internal" && typeof childrenAccessor === "function") {
|
|
31
|
+
throw new Error(`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.`);
|
|
32
|
+
}
|
|
33
|
+
const data = { [idKey]: `simple-tree-id-${nextId++}`, name: "" };
|
|
19
34
|
if (type === "internal")
|
|
20
|
-
data
|
|
35
|
+
data[childrenKey] = [];
|
|
21
36
|
tree.create({ parentId, index, data });
|
|
22
37
|
setData(tree.data);
|
|
23
38
|
return data;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { useSimpleTree } from "./use-simple-tree";
|
|
3
|
+
/* onCreate has to write a new node's id (and a folder's children) under a key
|
|
4
|
+
the accessors will read back. A function accessor can't be inverted to a key,
|
|
5
|
+
so creation with one must fail fast rather than return an unusable node
|
|
6
|
+
(issue #73 review follow-up). */
|
|
7
|
+
describe("useSimpleTree onCreate guards function accessors", () => {
|
|
8
|
+
function controllerFor(data, options) {
|
|
9
|
+
const { result } = renderHook(() => useSimpleTree(data, options));
|
|
10
|
+
return result.current[1];
|
|
11
|
+
}
|
|
12
|
+
const create = { parentId: null, parentNode: null, index: 0 };
|
|
13
|
+
test("throws when idAccessor is a function", () => {
|
|
14
|
+
const controller = controllerFor([{ uuid: "1", name: "a" }], { idAccessor: (d) => d.uuid });
|
|
15
|
+
expect(() => controller.onCreate(Object.assign(Object.assign({}, create), { type: "leaf" }))).toThrow(/idAccessor is a function/);
|
|
16
|
+
});
|
|
17
|
+
test("throws when creating a folder with a function childrenAccessor", () => {
|
|
18
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
19
|
+
childrenAccessor: (d) => d.kids,
|
|
20
|
+
});
|
|
21
|
+
expect(() => controller.onCreate(Object.assign(Object.assign({}, create), { type: "internal" }))).toThrow(/childrenAccessor is a function/);
|
|
22
|
+
});
|
|
23
|
+
test("a leaf can still be created when only childrenAccessor is a function", () => {
|
|
24
|
+
const controller = controllerFor([{ id: "1", name: "a" }], {
|
|
25
|
+
childrenAccessor: (d) => d.kids,
|
|
26
|
+
});
|
|
27
|
+
// onCreate calls setData, so run it inside act to keep the suite warning-clean.
|
|
28
|
+
expect(() => act(() => void controller.onCreate(Object.assign(Object.assign({}, create), { type: "leaf" })))).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -15,7 +15,10 @@ Use the data prop if you want to provide your own handlers.`);
|
|
|
15
15
|
*
|
|
16
16
|
* We will provide the real data and the handlers to update it.
|
|
17
17
|
* */
|
|
18
|
-
const [data, controller] = useSimpleTree(props.initialData
|
|
18
|
+
const [data, controller] = useSimpleTree(props.initialData, {
|
|
19
|
+
idAccessor: props.idAccessor,
|
|
20
|
+
childrenAccessor: props.childrenAccessor,
|
|
21
|
+
});
|
|
19
22
|
return Object.assign(Object.assign(Object.assign({}, props), controller), { data });
|
|
20
23
|
}
|
|
21
24
|
else {
|
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>) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NodeApi } from "../interfaces/node-api";
|
|
2
|
-
import { dragTypeForNode } from "./drag-hook";
|
|
2
|
+
import { canDragNode, dragTypeForNode } from "./drag-hook";
|
|
3
3
|
|
|
4
4
|
/* dragTypeForNode only reads node.data when dragType is a function, so a
|
|
5
5
|
minimal stub stands in for a real NodeApi. */
|
|
@@ -20,3 +20,21 @@ test("resolves a per-node dragType function against the node", () => {
|
|
|
20
20
|
expect(dragTypeForNode(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
21
21
|
expect(dragTypeForNode(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
/* canDragNode only reads the isDraggable/isEditing flags, so a minimal stub
|
|
25
|
+
stands in for a real NodeApi. */
|
|
26
|
+
function draggableNode(flags: { isDraggable: boolean; isEditing: boolean }): NodeApi {
|
|
27
|
+
return flags as NodeApi;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("a draggable node that isn't being edited can drag", () => {
|
|
31
|
+
expect(canDragNode(draggableNode({ isDraggable: true, isEditing: false }))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a non-draggable node can't drag", () => {
|
|
35
|
+
expect(canDragNode(draggableNode({ isDraggable: false, isEditing: false }))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a node being renamed can't drag, even when draggable (#195)", () => {
|
|
39
|
+
expect(canDragNode(draggableNode({ isDraggable: true, isEditing: true }))).toBe(false);
|
|
40
|
+
});
|
package/src/dnd/drag-hook.ts
CHANGED
|
@@ -15,12 +15,19 @@ export function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: Nod
|
|
|
15
15
|
return dragType ?? "NODE";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* A node can start a drag only when it's draggable and not currently being
|
|
19
|
+
renamed. Without the editing guard, dragging inside the rename input would
|
|
20
|
+
pick the row up and move it (issue #195). */
|
|
21
|
+
export function canDragNode<T>(node: NodeApi<T>): boolean {
|
|
22
|
+
return node.isDraggable && !node.isEditing;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
|
|
19
26
|
const tree = useTreeApi<T>();
|
|
20
27
|
const ids = tree.selectedIds;
|
|
21
28
|
const [_, ref, preview] = useDrag<DragItem<T>, DropResult, void>(
|
|
22
29
|
() => ({
|
|
23
|
-
canDrag: () => node
|
|
30
|
+
canDrag: () => canDragNode(node),
|
|
24
31
|
type: dragTypeForNode(tree.props.dragType, node),
|
|
25
32
|
item: () => {
|
|
26
33
|
// This is fired once at the beginning of a drag operation
|
|
@@ -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;
|