@byline/host-tanstack-start 3.12.2 → 3.13.1
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/admin-shell/collections/edit.js +1 -0
- package/dist/admin-shell/collections/tree-list-projection.d.ts +56 -0
- package/dist/admin-shell/collections/tree-list-projection.js +109 -0
- package/dist/admin-shell/collections/tree-list.d.ts +29 -0
- package/dist/admin-shell/collections/tree-list.js +272 -0
- package/dist/admin-shell/collections/tree-list.module.js +17 -0
- package/dist/admin-shell/collections/tree-list_module.css +117 -0
- package/dist/integrations/byline-field-services.js +25 -1
- package/dist/routes/create-collection-list-route.js +27 -2
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/tree.d.ts +84 -0
- package/dist/server-fns/collections/tree.js +144 -0
- package/package.json +8 -8
- package/src/admin-shell/collections/edit.tsx +1 -0
- package/src/admin-shell/collections/tree-list-projection.test.node.ts +116 -0
- package/src/admin-shell/collections/tree-list-projection.ts +198 -0
- package/src/admin-shell/collections/tree-list.module.css +140 -0
- package/src/admin-shell/collections/tree-list.tsx +354 -0
- package/src/integrations/byline-field-services.ts +29 -0
- package/src/routes/create-collection-list-route.tsx +39 -1
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/tree.ts +203 -0
|
@@ -8,7 +8,8 @@ import { z } from "zod";
|
|
|
8
8
|
import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
|
|
9
9
|
import { useNavigate } from "../admin-shell/chrome/loose-router.js";
|
|
10
10
|
import { ListView } from "../admin-shell/collections/list.js";
|
|
11
|
-
import {
|
|
11
|
+
import { TreeListView } from "../admin-shell/collections/tree-list.js";
|
|
12
|
+
import { getCollectionDocuments, getCollectionTree, placeTreeNode, reorderCollectionDocument } from "../server-fns/collections/index.js";
|
|
12
13
|
const searchSchema = z.object({
|
|
13
14
|
page: z.coerce.number().min(1).optional(),
|
|
14
15
|
page_size: z.coerce.number().max(100).optional(),
|
|
@@ -36,6 +37,12 @@ function createCollectionListRoute(path) {
|
|
|
36
37
|
loader: async ({ params, deps })=>{
|
|
37
38
|
const collectionDef = getCollectionDefinition(params.collection);
|
|
38
39
|
if (!collectionDef) throw notFound();
|
|
40
|
+
if (true === collectionDef.tree) return await getCollectionTree({
|
|
41
|
+
data: {
|
|
42
|
+
collection: params.collection,
|
|
43
|
+
locale: deps.locale
|
|
44
|
+
}
|
|
45
|
+
});
|
|
39
46
|
const adminConfig = getCollectionAdminConfig(params.collection);
|
|
40
47
|
const fields = adminConfig?.columns?.map((c)=>String(c.fieldName)).filter((name)=>collectionDef.fields.some((f)=>f.name === name));
|
|
41
48
|
const data = await getCollectionDocuments({
|
|
@@ -118,7 +125,25 @@ function createCollectionListRoute(path) {
|
|
|
118
125
|
}
|
|
119
126
|
]
|
|
120
127
|
}),
|
|
121
|
-
|
|
128
|
+
true === collectionDef.tree ? /*#__PURE__*/ jsx(TreeListView, {
|
|
129
|
+
rows: data.rows,
|
|
130
|
+
columns: columns,
|
|
131
|
+
workflowStatuses: workflowStatuses,
|
|
132
|
+
useAsTitle: collectionDef.useAsTitle,
|
|
133
|
+
collection: collection,
|
|
134
|
+
collectionLabels: data.included.collection.labels,
|
|
135
|
+
onMove: async ({ documentId, parentDocumentId, beforeDocumentId, afterDocumentId })=>{
|
|
136
|
+
await placeTreeNode({
|
|
137
|
+
data: {
|
|
138
|
+
collection,
|
|
139
|
+
documentId,
|
|
140
|
+
parentDocumentId,
|
|
141
|
+
beforeDocumentId,
|
|
142
|
+
afterDocumentId
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}) : CustomListView ? /*#__PURE__*/ jsx(CustomListView, {
|
|
122
147
|
data: data,
|
|
123
148
|
workflowStatuses: workflowStatuses
|
|
124
149
|
}) : /*#__PURE__*/ jsx(ListView, {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export declare const placeTreeNode: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
|
|
9
|
+
collection: string;
|
|
10
|
+
documentId: string;
|
|
11
|
+
parentDocumentId: string | null;
|
|
12
|
+
beforeDocumentId?: string | null;
|
|
13
|
+
afterDocumentId?: string | null;
|
|
14
|
+
}) => {
|
|
15
|
+
collection: string;
|
|
16
|
+
documentId: string;
|
|
17
|
+
parentDocumentId: string | null;
|
|
18
|
+
beforeDocumentId?: string | null;
|
|
19
|
+
afterDocumentId?: string | null;
|
|
20
|
+
}, Promise<{
|
|
21
|
+
status: "ok";
|
|
22
|
+
orderKey: string;
|
|
23
|
+
}>>;
|
|
24
|
+
export declare const removeFromTree: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
|
|
25
|
+
collection: string;
|
|
26
|
+
documentId: string;
|
|
27
|
+
}) => {
|
|
28
|
+
collection: string;
|
|
29
|
+
documentId: string;
|
|
30
|
+
}, Promise<{
|
|
31
|
+
status: "ok";
|
|
32
|
+
}>>;
|
|
33
|
+
export declare const getTreeAncestors: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
|
|
34
|
+
collection: string;
|
|
35
|
+
documentId: string;
|
|
36
|
+
}) => {
|
|
37
|
+
collection: string;
|
|
38
|
+
documentId: string;
|
|
39
|
+
}, Promise<{
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
path: string;
|
|
43
|
+
}[]>>;
|
|
44
|
+
export declare const getTreeParent: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
|
|
45
|
+
collection: string;
|
|
46
|
+
documentId: string;
|
|
47
|
+
}) => {
|
|
48
|
+
collection: string;
|
|
49
|
+
documentId: string;
|
|
50
|
+
}, Promise<{
|
|
51
|
+
placed: boolean;
|
|
52
|
+
parentDocumentId: string | null;
|
|
53
|
+
}>>;
|
|
54
|
+
export interface CollectionTreeRow {
|
|
55
|
+
id: string;
|
|
56
|
+
/** Parent document id, or `null` for a root or unplaced node. Drives the
|
|
57
|
+
* client-side tree reconstruction for drag-to-reorder / re-parent. */
|
|
58
|
+
parentId: string | null;
|
|
59
|
+
depth: number;
|
|
60
|
+
unplaced: boolean;
|
|
61
|
+
status: string;
|
|
62
|
+
path: string;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
updatedAt: Date;
|
|
65
|
+
fields: Record<string, any>;
|
|
66
|
+
}
|
|
67
|
+
export declare const getCollectionTree: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
|
|
68
|
+
collection: string;
|
|
69
|
+
locale?: string;
|
|
70
|
+
}) => {
|
|
71
|
+
collection: string;
|
|
72
|
+
locale?: string;
|
|
73
|
+
}, Promise<{
|
|
74
|
+
rows: CollectionTreeRow[];
|
|
75
|
+
included: {
|
|
76
|
+
collection: {
|
|
77
|
+
path: string;
|
|
78
|
+
labels: {
|
|
79
|
+
singular: string;
|
|
80
|
+
plural: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
}>>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { ERR_NOT_FOUND, getLogger } from "@byline/core";
|
|
3
|
+
import { ensureCollection } from "../../integrations/api-utils.js";
|
|
4
|
+
import { getAdminBylineClient } from "../../integrations/byline-client.js";
|
|
5
|
+
const placeTreeNode = createServerFn({
|
|
6
|
+
method: 'POST'
|
|
7
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
8
|
+
const { collection: path, documentId } = data;
|
|
9
|
+
const config = await ensureCollection(path);
|
|
10
|
+
if (!config) throw ERR_NOT_FOUND({
|
|
11
|
+
message: 'Collection not found',
|
|
12
|
+
details: {
|
|
13
|
+
collectionPath: path
|
|
14
|
+
}
|
|
15
|
+
}).log(getLogger());
|
|
16
|
+
const handle = getAdminBylineClient().collection(path);
|
|
17
|
+
const result = await handle.placeTreeNode(documentId, {
|
|
18
|
+
parentDocumentId: data.parentDocumentId,
|
|
19
|
+
beforeDocumentId: data.beforeDocumentId ?? null,
|
|
20
|
+
afterDocumentId: data.afterDocumentId ?? null
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
status: 'ok',
|
|
24
|
+
orderKey: result.orderKey
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
const removeFromTree = createServerFn({
|
|
28
|
+
method: 'POST'
|
|
29
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
30
|
+
const { collection: path, documentId } = data;
|
|
31
|
+
const config = await ensureCollection(path);
|
|
32
|
+
if (!config) throw ERR_NOT_FOUND({
|
|
33
|
+
message: 'Collection not found',
|
|
34
|
+
details: {
|
|
35
|
+
collectionPath: path
|
|
36
|
+
}
|
|
37
|
+
}).log(getLogger());
|
|
38
|
+
await getAdminBylineClient().collection(path).removeFromTree(documentId);
|
|
39
|
+
return {
|
|
40
|
+
status: 'ok'
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
const getTreeAncestors = createServerFn({
|
|
44
|
+
method: 'GET'
|
|
45
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
46
|
+
const { collection: path, documentId } = data;
|
|
47
|
+
const config = await ensureCollection(path);
|
|
48
|
+
if (!config) throw ERR_NOT_FOUND({
|
|
49
|
+
message: 'Collection not found',
|
|
50
|
+
details: {
|
|
51
|
+
collectionPath: path
|
|
52
|
+
}
|
|
53
|
+
}).log(getLogger());
|
|
54
|
+
const useAsTitle = config.definition.useAsTitle;
|
|
55
|
+
const ancestors = await getAdminBylineClient().collection(path).getAncestors(documentId, {
|
|
56
|
+
status: 'any'
|
|
57
|
+
});
|
|
58
|
+
return ancestors.map((doc)=>{
|
|
59
|
+
const fields = doc.fields;
|
|
60
|
+
const title = useAsTitle ? fields?.[useAsTitle] : void 0;
|
|
61
|
+
return {
|
|
62
|
+
id: doc.id,
|
|
63
|
+
title: 'string' == typeof title && title.length > 0 ? title : doc.path,
|
|
64
|
+
path: doc.path
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
const getTreeParent = createServerFn({
|
|
69
|
+
method: 'GET'
|
|
70
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
71
|
+
const { collection: path, documentId } = data;
|
|
72
|
+
const config = await ensureCollection(path);
|
|
73
|
+
if (!config) throw ERR_NOT_FOUND({
|
|
74
|
+
message: 'Collection not found',
|
|
75
|
+
details: {
|
|
76
|
+
collectionPath: path
|
|
77
|
+
}
|
|
78
|
+
}).log(getLogger());
|
|
79
|
+
return getAdminBylineClient().collection(path).getTreeParent(documentId);
|
|
80
|
+
});
|
|
81
|
+
const getCollectionTree = createServerFn({
|
|
82
|
+
method: 'GET'
|
|
83
|
+
}).validator((input)=>input).handler(async ({ data })=>{
|
|
84
|
+
const { collection: path } = data;
|
|
85
|
+
const config = await ensureCollection(path);
|
|
86
|
+
if (!config) throw ERR_NOT_FOUND({
|
|
87
|
+
message: 'Collection not found',
|
|
88
|
+
details: {
|
|
89
|
+
collectionPath: path
|
|
90
|
+
}
|
|
91
|
+
}).log(getLogger());
|
|
92
|
+
const handle = getAdminBylineClient().collection(path);
|
|
93
|
+
const forest = await handle.getSubtree({
|
|
94
|
+
status: 'any',
|
|
95
|
+
locale: data.locale
|
|
96
|
+
});
|
|
97
|
+
const rows = [];
|
|
98
|
+
const placed = new Set();
|
|
99
|
+
const walk = (nodes, depth, parentId)=>{
|
|
100
|
+
for (const node of nodes){
|
|
101
|
+
const doc = node.document;
|
|
102
|
+
rows.push({
|
|
103
|
+
id: doc.id,
|
|
104
|
+
parentId,
|
|
105
|
+
depth,
|
|
106
|
+
unplaced: false,
|
|
107
|
+
status: doc.status,
|
|
108
|
+
path: doc.path,
|
|
109
|
+
createdAt: doc.createdAt,
|
|
110
|
+
updatedAt: doc.updatedAt,
|
|
111
|
+
fields: doc.fields
|
|
112
|
+
});
|
|
113
|
+
placed.add(doc.id);
|
|
114
|
+
walk(node.children, depth + 1, doc.id);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
walk(forest, 0, null);
|
|
118
|
+
const all = await handle.find({
|
|
119
|
+
status: 'any',
|
|
120
|
+
pageSize: 1000,
|
|
121
|
+
_bypassBeforeRead: true
|
|
122
|
+
});
|
|
123
|
+
for (const doc of all.docs)if (!placed.has(doc.id)) rows.push({
|
|
124
|
+
id: doc.id,
|
|
125
|
+
parentId: null,
|
|
126
|
+
depth: 0,
|
|
127
|
+
unplaced: true,
|
|
128
|
+
status: doc.status,
|
|
129
|
+
path: doc.path,
|
|
130
|
+
createdAt: doc.createdAt,
|
|
131
|
+
updatedAt: doc.updatedAt,
|
|
132
|
+
fields: doc.fields
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
rows,
|
|
136
|
+
included: {
|
|
137
|
+
collection: {
|
|
138
|
+
path: config.collection.path,
|
|
139
|
+
labels: config.definition.labels
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
export { getCollectionTree, getTreeAncestors, getTreeParent, placeTreeNode, removeFromTree };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.13.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -119,13 +119,13 @@
|
|
|
119
119
|
"react-swipeable": "^7.0.2",
|
|
120
120
|
"uuid": "^14.0.0",
|
|
121
121
|
"zod": "^4.4.3",
|
|
122
|
-
"@byline/
|
|
123
|
-
"@byline/
|
|
124
|
-
"@byline/
|
|
125
|
-
"@byline/
|
|
126
|
-
"@byline/
|
|
127
|
-
"@byline/
|
|
128
|
-
"@byline/
|
|
122
|
+
"@byline/admin": "3.13.1",
|
|
123
|
+
"@byline/auth": "3.13.1",
|
|
124
|
+
"@byline/client": "3.13.1",
|
|
125
|
+
"@byline/i18n": "3.13.1",
|
|
126
|
+
"@byline/ai": "3.13.1",
|
|
127
|
+
"@byline/core": "3.13.1",
|
|
128
|
+
"@byline/ui": "3.13.1"
|
|
129
129
|
},
|
|
130
130
|
"peerDependencies": {
|
|
131
131
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -491,6 +491,7 @@ export const EditView = ({
|
|
|
491
491
|
useAsTitle={collectionDefinition.useAsTitle}
|
|
492
492
|
useAsPath={collectionDefinition.useAsPath}
|
|
493
493
|
advertiseLocales={collectionDefinition.advertiseLocales}
|
|
494
|
+
tree={collectionDefinition.tree === true}
|
|
494
495
|
headingLabel={labels.singular}
|
|
495
496
|
initialLocale={locale}
|
|
496
497
|
onLocaleChange={handleLocaleChange}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
applyProjection,
|
|
13
|
+
descendantIds,
|
|
14
|
+
getTreeProjection,
|
|
15
|
+
type TreeProjectionRow,
|
|
16
|
+
} from './tree-list-projection.js'
|
|
17
|
+
|
|
18
|
+
const INDENT = 24
|
|
19
|
+
|
|
20
|
+
// Pre-order placed tree:
|
|
21
|
+
// A (root)
|
|
22
|
+
// B (child of A)
|
|
23
|
+
// C (child of A)
|
|
24
|
+
// D (child of C)
|
|
25
|
+
// E (root)
|
|
26
|
+
const rows: TreeProjectionRow[] = [
|
|
27
|
+
{ id: 'A', parentId: null, depth: 0 },
|
|
28
|
+
{ id: 'B', parentId: 'A', depth: 1 },
|
|
29
|
+
{ id: 'C', parentId: 'A', depth: 1 },
|
|
30
|
+
{ id: 'D', parentId: 'C', depth: 2 },
|
|
31
|
+
{ id: 'E', parentId: null, depth: 0 },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
describe('descendantIds', () => {
|
|
35
|
+
it('returns the contiguous deeper rows after a node', () => {
|
|
36
|
+
expect([...descendantIds(rows, 'C')]).toEqual(['D'])
|
|
37
|
+
expect([...descendantIds(rows, 'A')]).toEqual(['B', 'C', 'D'])
|
|
38
|
+
expect([...descendantIds(rows, 'B')]).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('getTreeProjection', () => {
|
|
43
|
+
it('reorders within siblings (drop past a subtree, no horizontal offset)', () => {
|
|
44
|
+
// Drag B onto D (C's last descendant), no indent change → B lands after C's
|
|
45
|
+
// whole subtree, still a child of A.
|
|
46
|
+
expect(getTreeProjection(rows, 'B', 'D', 0, INDENT)).toEqual({
|
|
47
|
+
depth: 1,
|
|
48
|
+
parentId: 'A',
|
|
49
|
+
beforeId: 'C',
|
|
50
|
+
afterId: null,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('re-parents a node under the row above when dragged right one level', () => {
|
|
55
|
+
// Drag B onto C with a +1 level offset → B becomes C's first child, ahead
|
|
56
|
+
// of C's existing child D.
|
|
57
|
+
expect(getTreeProjection(rows, 'B', 'C', INDENT, INDENT)).toEqual({
|
|
58
|
+
depth: 2,
|
|
59
|
+
parentId: 'C',
|
|
60
|
+
beforeId: null,
|
|
61
|
+
afterId: 'D',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('outdents a deep node to a root when dragged far left', () => {
|
|
66
|
+
// Drag D onto E, far left → D becomes a root after E.
|
|
67
|
+
expect(getTreeProjection(rows, 'D', 'E', -2 * INDENT, INDENT)).toEqual({
|
|
68
|
+
depth: 0,
|
|
69
|
+
parentId: null,
|
|
70
|
+
beforeId: 'E',
|
|
71
|
+
afterId: null,
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('clamps depth to one level below the row above', () => {
|
|
76
|
+
// Drag E onto B with a huge right offset: dropping above B, the deepest E
|
|
77
|
+
// can go is a child of A (B's parent) — it cannot leap to B's depth+1.
|
|
78
|
+
expect(getTreeProjection(rows, 'E', 'B', 10 * INDENT, INDENT)).toEqual({
|
|
79
|
+
depth: 1,
|
|
80
|
+
parentId: 'A',
|
|
81
|
+
beforeId: null,
|
|
82
|
+
afterId: 'B',
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('is a no-op when dropping on itself', () => {
|
|
87
|
+
expect(getTreeProjection(rows, 'B', 'B', 0, INDENT)).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('is a no-op when dropping into its own subtree', () => {
|
|
91
|
+
// C cannot become a child of its own descendant D.
|
|
92
|
+
expect(getTreeProjection(rows, 'C', 'D', INDENT, INDENT)).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('applyProjection', () => {
|
|
97
|
+
const ids = (r: TreeProjectionRow[]) => r.map((x) => `${x.id}@${x.depth}`)
|
|
98
|
+
|
|
99
|
+
it('re-parents a node and its subtree together, shifting depth', () => {
|
|
100
|
+
// Move C (with child D) under E.
|
|
101
|
+
const projection = getTreeProjection(rows, 'C', 'E', INDENT, INDENT)!
|
|
102
|
+
const result = applyProjection(rows, 'C', projection)
|
|
103
|
+
// C now under E; D travels with C, one level deeper than C.
|
|
104
|
+
expect(ids(result)).toEqual(['A@0', 'B@1', 'E@0', 'C@1', 'D@2'])
|
|
105
|
+
expect(result.find((r) => r.id === 'C')?.parentId).toBe('E')
|
|
106
|
+
expect(result.find((r) => r.id === 'D')?.parentId).toBe('C')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('reorders a leaf within its sibling group', () => {
|
|
110
|
+
// Move B after C's subtree (drop on D, no indent) → A: [C, B].
|
|
111
|
+
const projection = getTreeProjection(rows, 'B', 'D', 0, INDENT)!
|
|
112
|
+
const result = applyProjection(rows, 'B', projection)
|
|
113
|
+
expect(ids(result)).toEqual(['A@0', 'C@1', 'D@2', 'B@1', 'E@0'])
|
|
114
|
+
expect(result.find((r) => r.id === 'B')?.parentId).toBe('A')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pure projection helpers for the drag-to-reorder / re-parent tree list
|
|
11
|
+
* (docs/DOCUMENT-TREE.md, phase 2). Adapted from the dnd-kit "sortable tree"
|
|
12
|
+
* pattern: a pre-order flattened list where the drag's **horizontal** offset
|
|
13
|
+
* projects a target depth, clamped to what the neighbouring rows allow, and the
|
|
14
|
+
* target parent + sibling neighbours are resolved from that depth.
|
|
15
|
+
*
|
|
16
|
+
* Kept free of React / dnd-kit so the projection is unit-testable in isolation;
|
|
17
|
+
* the component feeds it the live drag offset and applies the result through the
|
|
18
|
+
* `placeTreeNode` tree command.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Minimal row shape the projection needs (a slice of `CollectionTreeRow`). */
|
|
22
|
+
export interface TreeProjectionRow {
|
|
23
|
+
id: string
|
|
24
|
+
parentId: string | null
|
|
25
|
+
depth: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TreeProjection {
|
|
29
|
+
/** Projected depth of the dragged node at the drop position. */
|
|
30
|
+
depth: number
|
|
31
|
+
/** Resolved parent at that depth (`null` = root). */
|
|
32
|
+
parentId: string | null
|
|
33
|
+
/** Sibling immediately *above* the drop within the target parent (or null). */
|
|
34
|
+
beforeId: string | null
|
|
35
|
+
/** Sibling immediately *below* the drop within the target parent (or null). */
|
|
36
|
+
afterId: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clamp(value: number, min: number, max: number): number {
|
|
40
|
+
return Math.min(Math.max(value, min), max)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function arrayMove<T>(items: T[], from: number, to: number): T[] {
|
|
44
|
+
const next = items.slice()
|
|
45
|
+
const [moved] = next.splice(from, 1)
|
|
46
|
+
if (moved !== undefined) next.splice(to, 0, moved)
|
|
47
|
+
return next
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The ids of `activeId`'s descendants. In a pre-order list a node's descendants
|
|
52
|
+
* are the contiguous following rows with a strictly greater depth — they travel
|
|
53
|
+
* with the node (the adjacency edge to their parent is unchanged), so they are
|
|
54
|
+
* removed from the working list before projecting.
|
|
55
|
+
*/
|
|
56
|
+
export function descendantIds(rows: TreeProjectionRow[], activeId: string): Set<string> {
|
|
57
|
+
const ids = new Set<string>()
|
|
58
|
+
const start = rows.findIndex((r) => r.id === activeId)
|
|
59
|
+
if (start === -1) return ids
|
|
60
|
+
const baseDepth = rows[start]?.depth
|
|
61
|
+
for (let i = start + 1; i < rows.length && rows[i]?.depth > baseDepth; i++) {
|
|
62
|
+
ids.add(rows[i]?.id)
|
|
63
|
+
}
|
|
64
|
+
return ids
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Project where the dragged node would land. `rows` is the full pre-order placed
|
|
69
|
+
* tree; `dragOffsetX` is the pointer's horizontal delta in px; `indentWidth` is
|
|
70
|
+
* the px-per-depth indentation step. Returns `null` for a no-op drag (dropping
|
|
71
|
+
* on itself or into its own subtree).
|
|
72
|
+
*/
|
|
73
|
+
export function getTreeProjection(
|
|
74
|
+
rows: TreeProjectionRow[],
|
|
75
|
+
activeId: string,
|
|
76
|
+
overId: string,
|
|
77
|
+
dragOffsetX: number,
|
|
78
|
+
indentWidth: number
|
|
79
|
+
): TreeProjection | null {
|
|
80
|
+
if (activeId === overId) return null
|
|
81
|
+
|
|
82
|
+
// Drop into own subtree is invalid — the descendants travel with the node.
|
|
83
|
+
const descendants = descendantIds(rows, activeId)
|
|
84
|
+
if (descendants.has(overId)) return null
|
|
85
|
+
|
|
86
|
+
// Collapse the dragged subtree to a single unit for projection.
|
|
87
|
+
const working = rows.filter((r) => r.id === activeId || !descendants.has(r.id))
|
|
88
|
+
|
|
89
|
+
const activeIndex = working.findIndex((r) => r.id === activeId)
|
|
90
|
+
const overIndex = working.findIndex((r) => r.id === overId)
|
|
91
|
+
if (activeIndex === -1 || overIndex === -1) return null
|
|
92
|
+
|
|
93
|
+
const moved = arrayMove(working, activeIndex, overIndex)
|
|
94
|
+
const previous = moved[overIndex - 1]
|
|
95
|
+
const next = moved[overIndex + 1]
|
|
96
|
+
|
|
97
|
+
const dragDepth = Math.round(dragOffsetX / indentWidth)
|
|
98
|
+
const projectedDepth = working[activeIndex]?.depth + dragDepth
|
|
99
|
+
const maxDepth = previous ? previous.depth + 1 : 0
|
|
100
|
+
const minDepth = next ? next.depth : 0
|
|
101
|
+
const depth = clamp(projectedDepth, minDepth, maxDepth)
|
|
102
|
+
|
|
103
|
+
const parentId = resolveParentId(moved, overIndex, depth, previous)
|
|
104
|
+
|
|
105
|
+
// Sibling neighbours within the target parent, scanning out from the drop.
|
|
106
|
+
let beforeId: string | null = null
|
|
107
|
+
for (let i = overIndex - 1; i >= 0; i--) {
|
|
108
|
+
const row = moved[i]!
|
|
109
|
+
if (row.depth < depth) break
|
|
110
|
+
if (row.depth === depth && row.parentId === parentId) {
|
|
111
|
+
beforeId = row.id
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let afterId: string | null = null
|
|
116
|
+
for (let i = overIndex + 1; i < moved.length; i++) {
|
|
117
|
+
const row = moved[i]!
|
|
118
|
+
if (row.depth < depth) break
|
|
119
|
+
if (row.depth === depth && row.parentId === parentId) {
|
|
120
|
+
afterId = row.id
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { depth, parentId, beforeId, afterId }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply a projection to the flat row list — produce the new pre-order ordering
|
|
130
|
+
* for an **optimistic** repaint (the server returns the canonical order on the
|
|
131
|
+
* next read). The dragged node's whole subtree (contiguous deeper rows) travels
|
|
132
|
+
* with it; depths are shifted by the projected delta and the node's `parentId`
|
|
133
|
+
* is updated. Generic so it preserves the caller's full row shape.
|
|
134
|
+
*/
|
|
135
|
+
export function applyProjection<T extends TreeProjectionRow>(
|
|
136
|
+
rows: T[],
|
|
137
|
+
activeId: string,
|
|
138
|
+
p: TreeProjection
|
|
139
|
+
): T[] {
|
|
140
|
+
const start = rows.findIndex((r) => r.id === activeId)
|
|
141
|
+
if (start === -1) return rows
|
|
142
|
+
const baseDepth = rows[start]?.depth
|
|
143
|
+
|
|
144
|
+
let end = start + 1
|
|
145
|
+
while (end < rows.length && rows[end]?.depth > baseDepth) end++
|
|
146
|
+
|
|
147
|
+
const delta = p.depth - baseDepth
|
|
148
|
+
const block = rows
|
|
149
|
+
.slice(start, end)
|
|
150
|
+
.map((row, i) =>
|
|
151
|
+
i === 0
|
|
152
|
+
? { ...row, depth: row.depth + delta, parentId: p.parentId }
|
|
153
|
+
: { ...row, depth: row.depth + delta }
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const without = [...rows.slice(0, start), ...rows.slice(end)]
|
|
157
|
+
|
|
158
|
+
let insertAt: number
|
|
159
|
+
if (p.beforeId != null) {
|
|
160
|
+
const bi = without.findIndex((r) => r.id === p.beforeId)
|
|
161
|
+
if (bi === -1) {
|
|
162
|
+
insertAt = without.length
|
|
163
|
+
} else {
|
|
164
|
+
// after beforeId and its whole subtree
|
|
165
|
+
let j = bi + 1
|
|
166
|
+
const bd = without[bi]?.depth
|
|
167
|
+
while (j < without.length && without[j]?.depth > bd) j++
|
|
168
|
+
insertAt = j
|
|
169
|
+
}
|
|
170
|
+
} else if (p.afterId != null) {
|
|
171
|
+
const ai = without.findIndex((r) => r.id === p.afterId)
|
|
172
|
+
insertAt = ai === -1 ? without.length : ai
|
|
173
|
+
} else if (p.parentId != null) {
|
|
174
|
+
const pi = without.findIndex((r) => r.id === p.parentId)
|
|
175
|
+
insertAt = pi === -1 ? without.length : pi + 1
|
|
176
|
+
} else {
|
|
177
|
+
insertAt = without.length
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [...without.slice(0, insertAt), ...block, ...without.slice(insertAt)]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveParentId(
|
|
184
|
+
moved: TreeProjectionRow[],
|
|
185
|
+
overIndex: number,
|
|
186
|
+
depth: number,
|
|
187
|
+
previous: TreeProjectionRow | undefined
|
|
188
|
+
): string | null {
|
|
189
|
+
if (depth === 0 || previous == null) return null
|
|
190
|
+
if (depth === previous.depth) return previous.parentId
|
|
191
|
+
if (depth > previous.depth) return previous.id
|
|
192
|
+
// depth < previous.depth — find the nearest preceding row at the target depth
|
|
193
|
+
// and inherit its parent.
|
|
194
|
+
for (let i = overIndex - 1; i >= 0; i--) {
|
|
195
|
+
if (moved[i]?.depth === depth) return moved[i]?.parentId
|
|
196
|
+
}
|
|
197
|
+
return null
|
|
198
|
+
}
|