@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.
Files changed (35) hide show
  1. package/dist/fields/field-services-types.d.ts +50 -0
  2. package/dist/forms/form-context.js +30 -60
  3. package/dist/forms/form-modals.d.ts +30 -0
  4. package/dist/forms/form-modals.js +189 -0
  5. package/dist/forms/form-renderer.d.ts +8 -1
  6. package/dist/forms/form-renderer.js +31 -365
  7. package/dist/forms/form-status-display.d.ts +21 -0
  8. package/dist/forms/form-status-display.js +94 -0
  9. package/dist/forms/status-transitions.d.ts +23 -0
  10. package/dist/forms/status-transitions.js +37 -0
  11. package/dist/forms/status-transitions.test.node.d.ts +8 -0
  12. package/dist/forms/tree-placement-widget.d.ts +23 -0
  13. package/dist/forms/tree-placement-widget.js +176 -0
  14. package/dist/forms/tree-placement-widget.module.js +15 -0
  15. package/dist/forms/tree-placement-widget_module.css +69 -0
  16. package/dist/forms/use-form-layout.d.ts +35 -0
  17. package/dist/forms/use-form-layout.js +77 -0
  18. package/dist/forms/use-form-layout.test.node.d.ts +8 -0
  19. package/dist/forms/use-tracked-slot.d.ts +45 -0
  20. package/dist/forms/use-tracked-slot.js +46 -0
  21. package/dist/react.d.ts +1 -1
  22. package/package.json +5 -5
  23. package/src/fields/field-services-types.ts +50 -0
  24. package/src/forms/form-context.tsx +36 -92
  25. package/src/forms/form-modals.tsx +186 -0
  26. package/src/forms/form-renderer.tsx +42 -387
  27. package/src/forms/form-status-display.tsx +108 -0
  28. package/src/forms/status-transitions.test.node.ts +87 -0
  29. package/src/forms/status-transitions.ts +86 -0
  30. package/src/forms/tree-placement-widget.module.css +87 -0
  31. package/src/forms/tree-placement-widget.tsx +202 -0
  32. package/src/forms/use-form-layout.test.node.ts +82 -0
  33. package/src/forms/use-form-layout.ts +134 -0
  34. package/src/forms/use-tracked-slot.ts +101 -0
  35. 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.12.1",
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.12.1",
155
- "@byline/i18n": "3.12.1",
156
- "@byline/core": "3.12.1",
157
- "@byline/ui": "3.12.1"
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
  }