@byline/admin 3.12.1 → 3.13.0
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/fields/field-services-types.d.ts +50 -0
- package/dist/forms/form-context.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.d.ts +8 -1
- package/dist/forms/form-renderer.js +31 -365
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/tree-placement-widget.d.ts +23 -0
- package/dist/forms/tree-placement-widget.js +176 -0
- package/dist/forms/tree-placement-widget.module.js +15 -0
- package/dist/forms/tree-placement-widget_module.css +69 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/dist/react.d.ts +1 -1
- package/package.json +5 -5
- package/src/fields/field-services-types.ts +50 -0
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +42 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/tree-placement-widget.module.css +87 -0
- package/src/forms/tree-placement-widget.tsx +202 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
- package/src/react.ts +6 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface TreePlacementWidgetProps {
|
|
2
|
+
/** The collection path (`tree: true`). */
|
|
3
|
+
collectionPath: string;
|
|
4
|
+
/** The logical id of the document being edited. */
|
|
5
|
+
documentId: string;
|
|
6
|
+
/** The collection's `useAsTitle` field, used to label the chosen parent. */
|
|
7
|
+
useAsTitle?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Sidebar widget for placing the current document within its collection's
|
|
11
|
+
* single-parent document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md).
|
|
12
|
+
*
|
|
13
|
+
* The tree is document-grain and **unversioned**, so changes here write
|
|
14
|
+
* immediately (independent of the form's content save). The editor picks a
|
|
15
|
+
* parent through the collection's own relation picker — same search / columns
|
|
16
|
+
* UX as a relation field — or moves the document to the top level; the server
|
|
17
|
+
* enforces the cycle / same-collection invariants and fires the
|
|
18
|
+
* structural-change invalidation event.
|
|
19
|
+
*
|
|
20
|
+
* Renders only in edit mode (placement needs a persisted document) and only when
|
|
21
|
+
* the host wires the tree services. Stable override handle: `.byline-form-tree`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TreePlacementWidget: ({ collectionPath, documentId, useAsTitle, }: TreePlacementWidgetProps) => import("react").JSX.Element | null;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { getCollectionDefinition } from "@byline/core";
|
|
5
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
6
|
+
import { Button } from "@byline/ui/react";
|
|
7
|
+
import classnames from "classnames";
|
|
8
|
+
import { useBylineFieldServices } from "../fields/field-services-context.js";
|
|
9
|
+
import { RelationPicker } from "../fields/relation/relation-picker.js";
|
|
10
|
+
import tree_placement_widget_module from "./tree-placement-widget.module.js";
|
|
11
|
+
const TreePlacementWidget = ({ collectionPath, documentId, useAsTitle })=>{
|
|
12
|
+
const { t } = useTranslation('byline-admin');
|
|
13
|
+
const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices();
|
|
14
|
+
const targetDefinition = getCollectionDefinition(collectionPath);
|
|
15
|
+
const [parent, setParent] = useState(null);
|
|
16
|
+
const [placed, setPlaced] = useState(true);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
21
|
+
const treeServicesReady = null != getTreeAncestors && null != placeTreeNode;
|
|
22
|
+
useEffect(()=>{
|
|
23
|
+
if (null == getTreeAncestors) return;
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
setLoading(true);
|
|
26
|
+
Promise.all([
|
|
27
|
+
getTreeAncestors({
|
|
28
|
+
collection: collectionPath,
|
|
29
|
+
documentId
|
|
30
|
+
}),
|
|
31
|
+
getTreeParent?.({
|
|
32
|
+
collection: collectionPath,
|
|
33
|
+
documentId
|
|
34
|
+
}) ?? Promise.resolve({
|
|
35
|
+
placed: true,
|
|
36
|
+
parentDocumentId: null
|
|
37
|
+
})
|
|
38
|
+
]).then(([ancestors, placement])=>{
|
|
39
|
+
if (cancelled) return;
|
|
40
|
+
const immediate = ancestors.at(-1);
|
|
41
|
+
setParent(immediate ? {
|
|
42
|
+
id: immediate.id,
|
|
43
|
+
title: immediate.title
|
|
44
|
+
} : null);
|
|
45
|
+
setPlaced(placement.placed);
|
|
46
|
+
}).catch(()=>{
|
|
47
|
+
if (!cancelled) setError(t('treeWidget.error'));
|
|
48
|
+
}).finally(()=>{
|
|
49
|
+
if (!cancelled) setLoading(false);
|
|
50
|
+
});
|
|
51
|
+
return ()=>{
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [
|
|
55
|
+
getTreeAncestors,
|
|
56
|
+
getTreeParent,
|
|
57
|
+
collectionPath,
|
|
58
|
+
documentId,
|
|
59
|
+
t
|
|
60
|
+
]);
|
|
61
|
+
const place = useCallback(async (parentDocumentId, optimistic)=>{
|
|
62
|
+
if (null == placeTreeNode || busy) return;
|
|
63
|
+
const previousParent = parent;
|
|
64
|
+
const previousPlaced = placed;
|
|
65
|
+
setError(null);
|
|
66
|
+
setBusy(true);
|
|
67
|
+
setParent(optimistic);
|
|
68
|
+
setPlaced(true);
|
|
69
|
+
try {
|
|
70
|
+
await placeTreeNode({
|
|
71
|
+
collection: collectionPath,
|
|
72
|
+
documentId,
|
|
73
|
+
parentDocumentId
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
setParent(previousParent);
|
|
77
|
+
setPlaced(previousPlaced);
|
|
78
|
+
setError(t('treeWidget.error'));
|
|
79
|
+
} finally{
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
}, [
|
|
83
|
+
placeTreeNode,
|
|
84
|
+
busy,
|
|
85
|
+
parent,
|
|
86
|
+
placed,
|
|
87
|
+
collectionPath,
|
|
88
|
+
documentId,
|
|
89
|
+
t
|
|
90
|
+
]);
|
|
91
|
+
const handlePick = useCallback((selection)=>{
|
|
92
|
+
setPickerOpen(false);
|
|
93
|
+
const title = useAsTitle ? selection.record?.[useAsTitle] : void 0;
|
|
94
|
+
place(selection.targetDocumentId, {
|
|
95
|
+
id: selection.targetDocumentId,
|
|
96
|
+
title: 'string' == typeof title && title.length > 0 ? title : selection.targetDocumentId
|
|
97
|
+
});
|
|
98
|
+
}, [
|
|
99
|
+
place,
|
|
100
|
+
useAsTitle
|
|
101
|
+
]);
|
|
102
|
+
if (!treeServicesReady) return null;
|
|
103
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
104
|
+
className: classnames('byline-form-tree', tree_placement_widget_module.tree),
|
|
105
|
+
children: [
|
|
106
|
+
/*#__PURE__*/ jsx("span", {
|
|
107
|
+
className: classnames('byline-form-tree-heading', tree_placement_widget_module.heading),
|
|
108
|
+
children: t('treeWidget.label')
|
|
109
|
+
}),
|
|
110
|
+
/*#__PURE__*/ jsxs("div", {
|
|
111
|
+
className: classnames('byline-form-tree-current', tree_placement_widget_module.current),
|
|
112
|
+
children: [
|
|
113
|
+
/*#__PURE__*/ jsx("span", {
|
|
114
|
+
className: tree_placement_widget_module.parentLabel,
|
|
115
|
+
children: t('treeWidget.parentPrefix')
|
|
116
|
+
}),
|
|
117
|
+
' ',
|
|
118
|
+
placed ? parent ? /*#__PURE__*/ jsx("span", {
|
|
119
|
+
className: tree_placement_widget_module.parentValue,
|
|
120
|
+
children: parent.title
|
|
121
|
+
}) : /*#__PURE__*/ jsx("span", {
|
|
122
|
+
className: classnames(tree_placement_widget_module.parentValue, tree_placement_widget_module.root),
|
|
123
|
+
children: t('treeWidget.rootOption')
|
|
124
|
+
}) : /*#__PURE__*/ jsx("span", {
|
|
125
|
+
className: classnames(tree_placement_widget_module.parentValue, tree_placement_widget_module.root),
|
|
126
|
+
children: t('treeWidget.notInTree')
|
|
127
|
+
})
|
|
128
|
+
]
|
|
129
|
+
}),
|
|
130
|
+
/*#__PURE__*/ jsxs("div", {
|
|
131
|
+
className: classnames('byline-form-tree-actions', tree_placement_widget_module.actions),
|
|
132
|
+
children: [
|
|
133
|
+
/*#__PURE__*/ jsx(Button, {
|
|
134
|
+
type: "button",
|
|
135
|
+
size: "xs",
|
|
136
|
+
variant: "outlined",
|
|
137
|
+
intent: "noeffect",
|
|
138
|
+
disabled: loading || busy,
|
|
139
|
+
onClick: ()=>setPickerOpen(true),
|
|
140
|
+
children: t('treeWidget.choose')
|
|
141
|
+
}),
|
|
142
|
+
placed ? null != parent && /*#__PURE__*/ jsx("button", {
|
|
143
|
+
type: "button",
|
|
144
|
+
className: classnames('byline-form-tree-link', tree_placement_widget_module.link),
|
|
145
|
+
disabled: busy,
|
|
146
|
+
onClick: ()=>place(null, null),
|
|
147
|
+
children: t('treeWidget.makeRoot')
|
|
148
|
+
}) : /*#__PURE__*/ jsx("button", {
|
|
149
|
+
type: "button",
|
|
150
|
+
className: classnames('byline-form-tree-link', tree_placement_widget_module.link),
|
|
151
|
+
disabled: loading || busy,
|
|
152
|
+
onClick: ()=>place(null, null),
|
|
153
|
+
children: t('treeWidget.addToTree')
|
|
154
|
+
})
|
|
155
|
+
]
|
|
156
|
+
}),
|
|
157
|
+
null != error && /*#__PURE__*/ jsx("p", {
|
|
158
|
+
className: classnames('byline-form-tree-error', tree_placement_widget_module.error),
|
|
159
|
+
children: error
|
|
160
|
+
}),
|
|
161
|
+
null != targetDefinition && /*#__PURE__*/ jsx(RelationPicker, {
|
|
162
|
+
targetCollectionPath: collectionPath,
|
|
163
|
+
targetDefinition: targetDefinition,
|
|
164
|
+
displayField: useAsTitle,
|
|
165
|
+
isOpen: pickerOpen,
|
|
166
|
+
onSelect: handlePick,
|
|
167
|
+
onDismiss: ()=>setPickerOpen(false)
|
|
168
|
+
}),
|
|
169
|
+
/*#__PURE__*/ jsx("span", {
|
|
170
|
+
className: tree_placement_widget_module["sr-only"],
|
|
171
|
+
children: t("treeWidget.srDescription")
|
|
172
|
+
})
|
|
173
|
+
]
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
export { TreePlacementWidget };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "./tree-placement-widget_module.css";
|
|
2
|
+
const tree_placement_widget_module = {
|
|
3
|
+
tree: "tree-QTfVWc",
|
|
4
|
+
heading: "heading-kMlzJM",
|
|
5
|
+
current: "current-V4s8B9",
|
|
6
|
+
parentLabel: "parentLabel-AOKJXk",
|
|
7
|
+
parentValue: "parentValue-EzOVWx",
|
|
8
|
+
root: "root-mvHqUc",
|
|
9
|
+
actions: "actions-e3yRzc",
|
|
10
|
+
link: "link-hHX4SE",
|
|
11
|
+
error: "error-SXo8E4",
|
|
12
|
+
"sr-only": "sr-only-iZPr5G",
|
|
13
|
+
srOnly: "sr-only-iZPr5G"
|
|
14
|
+
};
|
|
15
|
+
export default tree_placement_widget_module;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
:is(.tree-QTfVWc, .byline-form-tree) {
|
|
2
|
+
gap: var(--spacing-8);
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
display: flex;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
:is(.heading-kMlzJM, .byline-form-tree-heading) {
|
|
8
|
+
font-size: .875rem;
|
|
9
|
+
font-weight: 600;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
:is(.current-V4s8B9, .byline-form-tree-current) {
|
|
13
|
+
font-size: .875rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.parentLabel-AOKJXk {
|
|
17
|
+
color: var(--gray-500);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.parentValue-EzOVWx {
|
|
21
|
+
font-weight: 500;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.root-mvHqUc {
|
|
25
|
+
color: var(--gray-500);
|
|
26
|
+
font-style: italic;
|
|
27
|
+
font-weight: 400;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:is(.actions-e3yRzc, .byline-form-tree-actions) {
|
|
31
|
+
flex-wrap: wrap;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: .75rem;
|
|
34
|
+
display: flex;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
:is(.link-hHX4SE, .byline-form-tree-link) {
|
|
38
|
+
color: inherit;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
background: none;
|
|
41
|
+
border: none;
|
|
42
|
+
padding: 0;
|
|
43
|
+
font-size: .8rem;
|
|
44
|
+
text-decoration: underline;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
:is(.link-hHX4SE:disabled, .byline-form-tree-link:disabled) {
|
|
48
|
+
opacity: .5;
|
|
49
|
+
cursor: default;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
:is(.error-SXo8E4, .byline-form-tree-error) {
|
|
53
|
+
color: var(--danger-600, #c0392b);
|
|
54
|
+
margin: 0;
|
|
55
|
+
font-size: .8rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.sr-only-iZPr5G {
|
|
59
|
+
clip: rect(0, 0, 0, 0);
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
border: 0;
|
|
62
|
+
width: 1px;
|
|
63
|
+
height: 1px;
|
|
64
|
+
margin: -1px;
|
|
65
|
+
padding: 0;
|
|
66
|
+
position: absolute;
|
|
67
|
+
overflow: hidden;
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CollectionAdminConfig, Field, GroupDefinition, LayoutDefinition, RowDefinition, TabSetDefinition } from '@byline/core';
|
|
2
|
+
/** Which tab set + tab a schema field is placed under. */
|
|
3
|
+
export interface TabPath {
|
|
4
|
+
tabSetName: string;
|
|
5
|
+
tabName: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Derived lookup tables that drive the form's layout walk and per-tab error
|
|
9
|
+
* badges. All are pure derivations of `adminConfig` + `fields`; the startup
|
|
10
|
+
* validator guarantees every reachable name resolves and every schema field is
|
|
11
|
+
* placed at most once, so the consuming render-time lookups stay unguarded.
|
|
12
|
+
*/
|
|
13
|
+
export interface FormLayout {
|
|
14
|
+
/** Schema field name → field definition. */
|
|
15
|
+
fieldByName: Map<string, Field>;
|
|
16
|
+
tabSetByName: Map<string, TabSetDefinition>;
|
|
17
|
+
rowByName: Map<string, RowDefinition>;
|
|
18
|
+
groupByName: Map<string, GroupDefinition>;
|
|
19
|
+
/** Region placement; synthesised to `{ main: <all fields> }` when omitted. */
|
|
20
|
+
layout: LayoutDefinition;
|
|
21
|
+
/** Reverse index: schema field name → which tab set + tab it lives in. */
|
|
22
|
+
fieldToTabPath: Map<string, TabPath>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the reverse index from schema field name to its enclosing tab set +
|
|
26
|
+
* tab. Fields not under any tab set (e.g. raw-field placement directly in
|
|
27
|
+
* `layout.main`) are absent from the map. Rows and groups are recursed into;
|
|
28
|
+
* the `seen` set guards against a config that references a row/group cycle.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildFieldToTabPath(adminConfig: CollectionAdminConfig | undefined, fieldByName: Map<string, Field>, rowByName: Map<string, RowDefinition>, groupByName: Map<string, GroupDefinition>): Map<string, TabPath>;
|
|
31
|
+
/**
|
|
32
|
+
* Memoise the layout primitives + lookup tables the form renderer walks.
|
|
33
|
+
* Rebuilt only when `adminConfig` / `fields` change.
|
|
34
|
+
*/
|
|
35
|
+
export declare function useFormLayout(adminConfig: CollectionAdminConfig | undefined, fields: Field[]): FormLayout;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
function buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName) {
|
|
4
|
+
const map = new Map();
|
|
5
|
+
const visit = (names, tabSetName, tabName, seen)=>{
|
|
6
|
+
for (const name of names)if (fieldByName.has(name)) map.set(name, {
|
|
7
|
+
tabSetName,
|
|
8
|
+
tabName
|
|
9
|
+
});
|
|
10
|
+
else if (seen.has(name)) ;
|
|
11
|
+
else if (rowByName.has(name)) {
|
|
12
|
+
const row = rowByName.get(name);
|
|
13
|
+
const next = new Set(seen).add(name);
|
|
14
|
+
visit(row.fields, tabSetName, tabName, next);
|
|
15
|
+
} else if (groupByName.has(name)) {
|
|
16
|
+
const group = groupByName.get(name);
|
|
17
|
+
const next = new Set(seen).add(name);
|
|
18
|
+
visit(group.fields, tabSetName, tabName, next);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
for (const set of adminConfig?.tabSets ?? [])for (const tab of set.tabs)visit(tab.fields, set.name, tab.name, new Set());
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
function useFormLayout(adminConfig, fields) {
|
|
25
|
+
const fieldByName = useMemo(()=>{
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const field of fields)if ('name' in field) map.set(field.name, field);
|
|
28
|
+
return map;
|
|
29
|
+
}, [
|
|
30
|
+
fields
|
|
31
|
+
]);
|
|
32
|
+
const tabSetByName = useMemo(()=>{
|
|
33
|
+
const map = new Map();
|
|
34
|
+
for (const set of adminConfig?.tabSets ?? [])map.set(set.name, set);
|
|
35
|
+
return map;
|
|
36
|
+
}, [
|
|
37
|
+
adminConfig
|
|
38
|
+
]);
|
|
39
|
+
const rowByName = useMemo(()=>{
|
|
40
|
+
const map = new Map();
|
|
41
|
+
for (const row of adminConfig?.rows ?? [])map.set(row.name, row);
|
|
42
|
+
return map;
|
|
43
|
+
}, [
|
|
44
|
+
adminConfig
|
|
45
|
+
]);
|
|
46
|
+
const groupByName = useMemo(()=>{
|
|
47
|
+
const map = new Map();
|
|
48
|
+
for (const group of adminConfig?.groups ?? [])map.set(group.name, group);
|
|
49
|
+
return map;
|
|
50
|
+
}, [
|
|
51
|
+
adminConfig
|
|
52
|
+
]);
|
|
53
|
+
const layout = useMemo(()=>{
|
|
54
|
+
if (adminConfig?.layout) return adminConfig.layout;
|
|
55
|
+
return {
|
|
56
|
+
main: fields.filter((f)=>'name' in f).map((f)=>f.name)
|
|
57
|
+
};
|
|
58
|
+
}, [
|
|
59
|
+
adminConfig,
|
|
60
|
+
fields
|
|
61
|
+
]);
|
|
62
|
+
const fieldToTabPath = useMemo(()=>buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName), [
|
|
63
|
+
adminConfig,
|
|
64
|
+
fieldByName,
|
|
65
|
+
rowByName,
|
|
66
|
+
groupByName
|
|
67
|
+
]);
|
|
68
|
+
return {
|
|
69
|
+
fieldByName,
|
|
70
|
+
tabSetByName,
|
|
71
|
+
rowByName,
|
|
72
|
+
groupByName,
|
|
73
|
+
layout,
|
|
74
|
+
fieldToTabPath
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export { buildFieldToTabPath, useFormLayout };
|
|
@@ -0,0 +1,8 @@
|
|
|
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 {};
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import { type RefObject } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* A dirty-tracked, ref-backed form slot with its own listener set — the shared
|
|
11
|
+
* machinery behind the document-grain system fields (`path`, advertised
|
|
12
|
+
* `availableLocales`, …). Each slot holds the current value, the value it was
|
|
13
|
+
* loaded with, and a set of subscribers; a `set` that moves the value away from
|
|
14
|
+
* its loaded baseline registers the slot's `dirtyKey` in the form's shared
|
|
15
|
+
* dirty set (and clears it again when the value returns to baseline), so the
|
|
16
|
+
* single Save button can branch on which buckets changed.
|
|
17
|
+
*
|
|
18
|
+
* Adding a new system slot is then a one-liner at the call site rather than a
|
|
19
|
+
* copy of ref + initial-ref + listener-set + get/set/subscribe.
|
|
20
|
+
*/
|
|
21
|
+
export interface TrackedSlot<T> {
|
|
22
|
+
/** Current value (live ref read — not React state). */
|
|
23
|
+
get: () => T;
|
|
24
|
+
/** Write a value; toggles `dirtyKey` against the loaded baseline. */
|
|
25
|
+
set: (value: T) => void;
|
|
26
|
+
/** Subscribe to value changes; returns an unsubscribe fn. */
|
|
27
|
+
subscribe: (listener: (value: T) => void) => () => void;
|
|
28
|
+
/** Re-baseline: adopt the current value as the new "clean" baseline (called on save). */
|
|
29
|
+
commitInitial: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface UseTrackedSlotConfig<T> {
|
|
32
|
+
/** Initial / loaded value. */
|
|
33
|
+
initial: T;
|
|
34
|
+
/** Key registered in the shared dirty set while this slot diverges from baseline. */
|
|
35
|
+
dirtyKey: string;
|
|
36
|
+
/** The form's shared dirty-key set. */
|
|
37
|
+
dirtyFields: RefObject<Set<string>>;
|
|
38
|
+
/** Notify the form's meta listeners (drives hasChanges → Save button). */
|
|
39
|
+
notifyMeta: () => void;
|
|
40
|
+
/** Equality against the baseline. Defaults to `===` (identity). */
|
|
41
|
+
isEqual?: (a: T, b: T) => boolean;
|
|
42
|
+
/** Defensive copy on read-in / write. Defaults to identity (fine for immutables). */
|
|
43
|
+
clone?: (value: T) => T;
|
|
44
|
+
}
|
|
45
|
+
export declare function useTrackedSlot<T>(config: UseTrackedSlotConfig<T>): TrackedSlot<T>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef } from "react";
|
|
3
|
+
function useTrackedSlot(config) {
|
|
4
|
+
const cfgRef = useRef(config);
|
|
5
|
+
cfgRef.current = config;
|
|
6
|
+
const clone = useCallback((value)=>{
|
|
7
|
+
const fn = cfgRef.current.clone;
|
|
8
|
+
return fn ? fn(value) : value;
|
|
9
|
+
}, []);
|
|
10
|
+
const valueRef = useRef(clone(config.initial));
|
|
11
|
+
const initialRef = useRef(clone(config.initial));
|
|
12
|
+
const listeners = useRef(new Set());
|
|
13
|
+
const get = useCallback(()=>valueRef.current, []);
|
|
14
|
+
const set = useCallback((value)=>{
|
|
15
|
+
const { dirtyKey, dirtyFields, notifyMeta, isEqual } = cfgRef.current;
|
|
16
|
+
const next = clone(value);
|
|
17
|
+
valueRef.current = next;
|
|
18
|
+
const equal = isEqual ? isEqual(next, initialRef.current) : next === initialRef.current;
|
|
19
|
+
if (equal) dirtyFields.current.delete(dirtyKey);
|
|
20
|
+
else dirtyFields.current.add(dirtyKey);
|
|
21
|
+
listeners.current.forEach((listener)=>{
|
|
22
|
+
listener(next);
|
|
23
|
+
});
|
|
24
|
+
notifyMeta();
|
|
25
|
+
}, [
|
|
26
|
+
clone
|
|
27
|
+
]);
|
|
28
|
+
const subscribe = useCallback((listener)=>{
|
|
29
|
+
listeners.current.add(listener);
|
|
30
|
+
return ()=>{
|
|
31
|
+
listeners.current.delete(listener);
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
const commitInitial = useCallback(()=>{
|
|
35
|
+
initialRef.current = clone(valueRef.current);
|
|
36
|
+
}, [
|
|
37
|
+
clone
|
|
38
|
+
]);
|
|
39
|
+
return {
|
|
40
|
+
get,
|
|
41
|
+
set,
|
|
42
|
+
subscribe,
|
|
43
|
+
commitInitial
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export { useTrackedSlot };
|
package/dist/react.d.ts
CHANGED
|
@@ -64,4 +64,4 @@ export * from './presentation/tabs.js';
|
|
|
64
64
|
export * from './widgets/diff-viewer/diff-modal.js';
|
|
65
65
|
export * from './widgets/source-locale-badge/source-locale-badge.js';
|
|
66
66
|
export * from './widgets/status-badge/status-badge.js';
|
|
67
|
-
export type { BylineFieldServices, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
|
|
67
|
+
export type { BylineFieldServices, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, GetTreeAncestorsFn, GetTreeParentFn, PlaceTreeNodeFn, PlaceTreeNodeInput, RemoveFromTreeFn, TreeAncestor, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.13.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -151,10 +151,10 @@
|
|
|
151
151
|
"uuid": "^14.0.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
153
|
"zod-form-data": "^3.0.1",
|
|
154
|
-
"@byline/auth": "3.
|
|
155
|
-
"@byline/
|
|
156
|
-
"@byline/
|
|
157
|
-
"@byline/
|
|
154
|
+
"@byline/auth": "3.13.0",
|
|
155
|
+
"@byline/ui": "3.13.0",
|
|
156
|
+
"@byline/i18n": "3.13.0",
|
|
157
|
+
"@byline/core": "3.13.0"
|
|
158
158
|
},
|
|
159
159
|
"peerDependencies": {
|
|
160
160
|
"react": "^19.0.0",
|
|
@@ -62,7 +62,57 @@ export type UploadFieldFn = (
|
|
|
62
62
|
createDocument?: boolean
|
|
63
63
|
) => Promise<UploadedFileResult>
|
|
64
64
|
|
|
65
|
+
// --- Document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md) -----
|
|
66
|
+
|
|
67
|
+
/** One hydrated ancestor in a document's breadcrumb trail (root-first). */
|
|
68
|
+
export interface TreeAncestor {
|
|
69
|
+
id: string
|
|
70
|
+
title: string
|
|
71
|
+
path?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PlaceTreeNodeInput {
|
|
75
|
+
collection: string
|
|
76
|
+
documentId: string
|
|
77
|
+
/** The new parent; `null` makes the document a root node. */
|
|
78
|
+
parentDocumentId: string | null
|
|
79
|
+
/** Optional sibling neighbours (left = land after, right = land before). */
|
|
80
|
+
beforeDocumentId?: string | null
|
|
81
|
+
afterDocumentId?: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Place / move a document within its collection's tree. */
|
|
85
|
+
export type PlaceTreeNodeFn = (input: PlaceTreeNodeInput) => Promise<{ orderKey: string }>
|
|
86
|
+
|
|
87
|
+
/** Remove a document from the tree (back to the unplaced state). */
|
|
88
|
+
export type RemoveFromTreeFn = (input: { collection: string; documentId: string }) => Promise<void>
|
|
89
|
+
|
|
90
|
+
/** Resolve a document's ancestor chain, root-first, hydrated with titles. */
|
|
91
|
+
export type GetTreeAncestorsFn = (input: {
|
|
92
|
+
collection: string
|
|
93
|
+
documentId: string
|
|
94
|
+
}) => Promise<TreeAncestor[]>
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a document's placement state — the tri-state (unplaced / root / child)
|
|
98
|
+
* that `getTreeAncestors` cannot express (it returns `[]` for both root and
|
|
99
|
+
* unplaced). `placed: false` = unplaced; `placed: true` + null parent = root.
|
|
100
|
+
*/
|
|
101
|
+
export type GetTreeParentFn = (input: {
|
|
102
|
+
collection: string
|
|
103
|
+
documentId: string
|
|
104
|
+
}) => Promise<{ placed: boolean; parentDocumentId: string | null }>
|
|
105
|
+
|
|
65
106
|
export interface BylineFieldServices {
|
|
66
107
|
getCollectionDocuments: GetCollectionDocumentsFn
|
|
67
108
|
uploadField: UploadFieldFn
|
|
109
|
+
/**
|
|
110
|
+
* Document-tree operations, consumed by the sidebar tree-placement widget.
|
|
111
|
+
* Optional — only hosts that serve `tree: true` collections need to wire
|
|
112
|
+
* them; the widget guards on their presence.
|
|
113
|
+
*/
|
|
114
|
+
placeTreeNode?: PlaceTreeNodeFn
|
|
115
|
+
removeFromTree?: RemoveFromTreeFn
|
|
116
|
+
getTreeAncestors?: GetTreeAncestorsFn
|
|
117
|
+
getTreeParent?: GetTreeParentFn
|
|
68
118
|
}
|