@byline/admin 3.12.2 → 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.
@@ -57,7 +57,57 @@ export interface UploadedFileResult {
57
57
  storedFile: StoredFileValue;
58
58
  }
59
59
  export type UploadFieldFn = (collection: string, formData: FormData, createDocument?: boolean) => Promise<UploadedFileResult>;
60
+ /** One hydrated ancestor in a document's breadcrumb trail (root-first). */
61
+ export interface TreeAncestor {
62
+ id: string;
63
+ title: string;
64
+ path?: string;
65
+ }
66
+ export interface PlaceTreeNodeInput {
67
+ collection: string;
68
+ documentId: string;
69
+ /** The new parent; `null` makes the document a root node. */
70
+ parentDocumentId: string | null;
71
+ /** Optional sibling neighbours (left = land after, right = land before). */
72
+ beforeDocumentId?: string | null;
73
+ afterDocumentId?: string | null;
74
+ }
75
+ /** Place / move a document within its collection's tree. */
76
+ export type PlaceTreeNodeFn = (input: PlaceTreeNodeInput) => Promise<{
77
+ orderKey: string;
78
+ }>;
79
+ /** Remove a document from the tree (back to the unplaced state). */
80
+ export type RemoveFromTreeFn = (input: {
81
+ collection: string;
82
+ documentId: string;
83
+ }) => Promise<void>;
84
+ /** Resolve a document's ancestor chain, root-first, hydrated with titles. */
85
+ export type GetTreeAncestorsFn = (input: {
86
+ collection: string;
87
+ documentId: string;
88
+ }) => Promise<TreeAncestor[]>;
89
+ /**
90
+ * Resolve a document's placement state — the tri-state (unplaced / root / child)
91
+ * that `getTreeAncestors` cannot express (it returns `[]` for both root and
92
+ * unplaced). `placed: false` = unplaced; `placed: true` + null parent = root.
93
+ */
94
+ export type GetTreeParentFn = (input: {
95
+ collection: string;
96
+ documentId: string;
97
+ }) => Promise<{
98
+ placed: boolean;
99
+ parentDocumentId: string | null;
100
+ }>;
60
101
  export interface BylineFieldServices {
61
102
  getCollectionDocuments: GetCollectionDocumentsFn;
62
103
  uploadField: UploadFieldFn;
104
+ /**
105
+ * Document-tree operations, consumed by the sidebar tree-placement widget.
106
+ * Optional — only hosts that serve `tree: true` collections need to wire
107
+ * them; the widget guards on their presence.
108
+ */
109
+ placeTreeNode?: PlaceTreeNodeFn;
110
+ removeFromTree?: RemoveFromTreeFn;
111
+ getTreeAncestors?: GetTreeAncestorsFn;
112
+ getTreeParent?: GetTreeParentFn;
63
113
  }
@@ -100,6 +100,13 @@ export interface FormRendererProps {
100
100
  * against the document's `_availableVersionLocales` ledger fact.
101
101
  */
102
102
  advertiseLocales?: boolean;
103
+ /**
104
+ * Opts the document-tree placement widget into the sidebar (above the
105
+ * available-locales widget). Sourced from `CollectionDefinition.tree` by the
106
+ * caller. Renders only in edit mode (placement needs a persisted document)
107
+ * and only when the host wires the tree services. See docs/DOCUMENT-TREE.md.
108
+ */
109
+ tree?: boolean;
103
110
  headingLabel?: string;
104
111
  headerSlot?: ReactNode;
105
112
  /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
@@ -129,4 +136,4 @@ export interface FormRendererProps {
129
136
  */
130
137
  useNavigationGuard?: UseNavigationGuard;
131
138
  }
132
- export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
139
+ export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
@@ -18,9 +18,10 @@ import { FormStatusDisplay } from "./form-status-display.js";
18
18
  import { useNavigationGuardAdapter } from "./navigation-guard.js";
19
19
  import { PathWidget } from "./path-widget.js";
20
20
  import { computeStatusTransitions } from "./status-transitions.js";
21
+ import { TreePlacementWidget } from "./tree-placement-widget.js";
21
22
  import { executeUploadsWithProgress } from "./upload-executor.js";
22
23
  import { useFormLayout } from "./use-form-layout.js";
23
- const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
24
+ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
24
25
  const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
25
26
  const { t } = useTranslation('byline-admin');
26
27
  const [errors, setErrors] = useState(initialErrors);
@@ -346,6 +347,11 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
346
347
  activeLocale: contentLocale,
347
348
  mode: mode
348
349
  }),
350
+ tree && 'edit' === mode && 'string' == typeof initialData?.id && /*#__PURE__*/ jsx(TreePlacementWidget, {
351
+ collectionPath: collectionPath ?? '',
352
+ documentId: initialData.id,
353
+ useAsTitle: useAsTitle
354
+ }),
349
355
  advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
350
356
  contentLocales: contentLocales ?? [],
351
357
  availableVersionLocales: initialData?._availableVersionLocales ?? []
@@ -376,7 +382,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
376
382
  ]
377
383
  });
378
384
  };
379
- const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
385
+ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
380
386
  const savedTabsRef = useRef({});
381
387
  return /*#__PURE__*/ jsx(FormProvider, {
382
388
  initialData: initialData,
@@ -400,6 +406,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
400
406
  useAsTitle: useAsTitle,
401
407
  useAsPath: useAsPath,
402
408
  advertiseLocales: advertiseLocales,
409
+ tree: tree,
403
410
  headingLabel: headingLabel,
404
411
  headerSlot: headerSlot,
405
412
  collectionPath: collectionPath,
@@ -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
+
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.2",
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.2",
155
- "@byline/ui": "3.12.2",
156
- "@byline/core": "3.12.2",
157
- "@byline/i18n": "3.12.2"
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
  }
@@ -37,6 +37,7 @@ import { FormStatusDisplay } from './form-status-display'
37
37
  import { useNavigationGuardAdapter } from './navigation-guard'
38
38
  import { PathWidget } from './path-widget'
39
39
  import { computeStatusTransitions } from './status-transitions'
40
+ import { TreePlacementWidget } from './tree-placement-widget'
40
41
  import { executeUploadsWithProgress } from './upload-executor'
41
42
  import { useFormLayout } from './use-form-layout'
42
43
  import type { UseNavigationGuard } from './navigation-guard'
@@ -129,6 +130,13 @@ export interface FormRendererProps {
129
130
  * against the document's `_availableVersionLocales` ledger fact.
130
131
  */
131
132
  advertiseLocales?: boolean
133
+ /**
134
+ * Opts the document-tree placement widget into the sidebar (above the
135
+ * available-locales widget). Sourced from `CollectionDefinition.tree` by the
136
+ * caller. Renders only in edit mode (placement needs a persisted document)
137
+ * and only when the host wires the tree services. See docs/DOCUMENT-TREE.md.
138
+ */
139
+ tree?: boolean
132
140
  headingLabel?: string
133
141
  headerSlot?: ReactNode
134
142
  /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
@@ -179,6 +187,7 @@ const FormContent = ({
179
187
  useAsTitle,
180
188
  useAsPath,
181
189
  advertiseLocales,
190
+ tree,
182
191
  headingLabel,
183
192
  headerSlot,
184
193
  collectionPath,
@@ -665,6 +674,13 @@ const FormContent = ({
665
674
  mode={mode}
666
675
  />
667
676
  )}
677
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
678
+ <TreePlacementWidget
679
+ collectionPath={collectionPath ?? ''}
680
+ documentId={initialData.id as string}
681
+ useAsTitle={useAsTitle}
682
+ />
683
+ )}
668
684
  {advertiseLocales && (
669
685
  <AvailableLocalesWidget
670
686
  contentLocales={contentLocales ?? []}
@@ -715,6 +731,7 @@ export const FormRenderer = ({
715
731
  useAsTitle,
716
732
  useAsPath,
717
733
  advertiseLocales,
734
+ tree,
718
735
  headingLabel,
719
736
  headerSlot,
720
737
  collectionPath,
@@ -753,6 +770,7 @@ export const FormRenderer = ({
753
770
  useAsTitle={useAsTitle}
754
771
  useAsPath={useAsPath}
755
772
  advertiseLocales={advertiseLocales}
773
+ tree={tree}
756
774
  headingLabel={headingLabel}
757
775
  headerSlot={headerSlot}
758
776
  collectionPath={collectionPath}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * TreePlacementWidget — sidebar widget for placing a document within its
3
+ * collection's single-parent tree (the `tree: true` primitive).
4
+ *
5
+ * Override handles:
6
+ * .byline-form-tree — wrapper div
7
+ * .byline-form-tree-current — current-parent line
8
+ * .byline-form-tree-actions — actions row
9
+ * .byline-form-tree-link — text-button actions (move to top level / remove)
10
+ * .byline-form-tree-error — inline error
11
+ */
12
+
13
+ .tree,
14
+ :global(.byline-form-tree) {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: var(--spacing-8);
18
+ }
19
+
20
+ .heading,
21
+ :global(.byline-form-tree-heading) {
22
+ font-size: 0.875rem;
23
+ font-weight: 600;
24
+ }
25
+
26
+ .current,
27
+ :global(.byline-form-tree-current) {
28
+ font-size: 0.875rem;
29
+ }
30
+
31
+ .parentLabel {
32
+ color: var(--gray-500);
33
+ }
34
+
35
+ .parentValue {
36
+ font-weight: 500;
37
+ }
38
+
39
+ .root {
40
+ font-style: italic;
41
+ font-weight: 400;
42
+ color: var(--gray-500);
43
+ }
44
+
45
+ .actions,
46
+ :global(.byline-form-tree-actions) {
47
+ display: flex;
48
+ align-items: center;
49
+ flex-wrap: wrap;
50
+ gap: 0.75rem;
51
+ }
52
+
53
+ .link,
54
+ :global(.byline-form-tree-link) {
55
+ background: none;
56
+ border: none;
57
+ padding: 0;
58
+ color: inherit;
59
+ font-size: 0.8rem;
60
+ text-decoration: underline;
61
+ cursor: pointer;
62
+ }
63
+
64
+ .link:disabled,
65
+ :global(.byline-form-tree-link):disabled {
66
+ opacity: 0.5;
67
+ cursor: default;
68
+ }
69
+
70
+ .error,
71
+ :global(.byline-form-tree-error) {
72
+ margin: 0;
73
+ font-size: 0.8rem;
74
+ color: var(--danger-600, #c0392b);
75
+ }
76
+
77
+ .sr-only {
78
+ position: absolute;
79
+ width: 1px;
80
+ height: 1px;
81
+ padding: 0;
82
+ margin: -1px;
83
+ overflow: hidden;
84
+ clip: rect(0, 0, 0, 0);
85
+ white-space: nowrap;
86
+ border: 0;
87
+ }
@@ -0,0 +1,202 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useEffect, useState } from 'react'
12
+
13
+ import { getCollectionDefinition } from '@byline/core'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { Button } from '@byline/ui/react'
16
+ import cx from 'classnames'
17
+
18
+ import { useBylineFieldServices } from '../fields/field-services-context.js'
19
+ import { RelationPicker } from '../fields/relation/relation-picker.js'
20
+ import styles from './tree-placement-widget.module.css'
21
+
22
+ export interface TreePlacementWidgetProps {
23
+ /** The collection path (`tree: true`). */
24
+ collectionPath: string
25
+ /** The logical id of the document being edited. */
26
+ documentId: string
27
+ /** The collection's `useAsTitle` field, used to label the chosen parent. */
28
+ useAsTitle?: string
29
+ }
30
+
31
+ /**
32
+ * Sidebar widget for placing the current document within its collection's
33
+ * single-parent document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md).
34
+ *
35
+ * The tree is document-grain and **unversioned**, so changes here write
36
+ * immediately (independent of the form's content save). The editor picks a
37
+ * parent through the collection's own relation picker — same search / columns
38
+ * UX as a relation field — or moves the document to the top level; the server
39
+ * enforces the cycle / same-collection invariants and fires the
40
+ * structural-change invalidation event.
41
+ *
42
+ * Renders only in edit mode (placement needs a persisted document) and only when
43
+ * the host wires the tree services. Stable override handle: `.byline-form-tree`.
44
+ */
45
+ export const TreePlacementWidget = ({
46
+ collectionPath,
47
+ documentId,
48
+ useAsTitle,
49
+ }: TreePlacementWidgetProps) => {
50
+ const { t } = useTranslation('byline-admin')
51
+ const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices()
52
+ const targetDefinition = getCollectionDefinition(collectionPath)
53
+
54
+ const [parent, setParent] = useState<{ id: string; title: string } | null>(null)
55
+ // Whether the document has an edge row at all. `false` = *unplaced* (no row);
56
+ // `true` with a null `parent` = a *root*. Distinguishing the two drives the
57
+ // "Add to tree" (unplaced) vs "Top level" (root) display. Hosts that don't
58
+ // wire `getTreeParent` fall back to `true` — the pre-tri-state behaviour.
59
+ const [placed, setPlaced] = useState(true)
60
+ const [loading, setLoading] = useState(true)
61
+ const [busy, setBusy] = useState(false)
62
+ const [error, setError] = useState<string | null>(null)
63
+ const [pickerOpen, setPickerOpen] = useState(false)
64
+
65
+ const treeServicesReady = getTreeAncestors != null && placeTreeNode != null
66
+
67
+ // Load the document's placement state and current parent on mount. The parent
68
+ // title comes from the (hydrated) ancestor chain; `getTreeParent` (when wired)
69
+ // tells unplaced from root, which the ancestor chain alone cannot.
70
+ useEffect(() => {
71
+ if (getTreeAncestors == null) return
72
+ let cancelled = false
73
+ setLoading(true)
74
+ Promise.all([
75
+ getTreeAncestors({ collection: collectionPath, documentId }),
76
+ getTreeParent?.({ collection: collectionPath, documentId }) ??
77
+ Promise.resolve({ placed: true, parentDocumentId: null }),
78
+ ])
79
+ .then(([ancestors, placement]) => {
80
+ if (cancelled) return
81
+ const immediate = ancestors.at(-1)
82
+ setParent(immediate ? { id: immediate.id, title: immediate.title } : null)
83
+ setPlaced(placement.placed)
84
+ })
85
+ .catch(() => {
86
+ if (!cancelled) setError(t('treeWidget.error'))
87
+ })
88
+ .finally(() => {
89
+ if (!cancelled) setLoading(false)
90
+ })
91
+ return () => {
92
+ cancelled = true
93
+ }
94
+ }, [getTreeAncestors, getTreeParent, collectionPath, documentId, t])
95
+
96
+ // Place (or re-parent) the node, optimistically updating the current-parent
97
+ // display and reverting on a server rejection (e.g. a cycle). Any successful
98
+ // placement (including making the node a root) leaves it placed.
99
+ const place = useCallback(
100
+ async (parentDocumentId: string | null, optimistic: { id: string; title: string } | null) => {
101
+ if (placeTreeNode == null || busy) return
102
+ const previousParent = parent
103
+ const previousPlaced = placed
104
+ setError(null)
105
+ setBusy(true)
106
+ setParent(optimistic)
107
+ setPlaced(true)
108
+ try {
109
+ await placeTreeNode({ collection: collectionPath, documentId, parentDocumentId })
110
+ } catch {
111
+ setParent(previousParent)
112
+ setPlaced(previousPlaced)
113
+ setError(t('treeWidget.error'))
114
+ } finally {
115
+ setBusy(false)
116
+ }
117
+ },
118
+ [placeTreeNode, busy, parent, placed, collectionPath, documentId, t]
119
+ )
120
+
121
+ const handlePick = useCallback(
122
+ (selection: { targetDocumentId: string; record?: Record<string, any> }) => {
123
+ setPickerOpen(false)
124
+ const title = useAsTitle ? selection.record?.[useAsTitle] : undefined
125
+ place(selection.targetDocumentId, {
126
+ id: selection.targetDocumentId,
127
+ title: typeof title === 'string' && title.length > 0 ? title : selection.targetDocumentId,
128
+ })
129
+ },
130
+ [place, useAsTitle]
131
+ )
132
+
133
+ if (!treeServicesReady) return null
134
+
135
+ return (
136
+ <div className={cx('byline-form-tree', styles.tree)}>
137
+ <span className={cx('byline-form-tree-heading', styles.heading)}>
138
+ {t('treeWidget.label')}
139
+ </span>
140
+
141
+ <div className={cx('byline-form-tree-current', styles.current)}>
142
+ <span className={styles.parentLabel}>{t('treeWidget.parentPrefix')}</span>{' '}
143
+ {!placed ? (
144
+ <span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.notInTree')}</span>
145
+ ) : parent ? (
146
+ <span className={styles.parentValue}>{parent.title}</span>
147
+ ) : (
148
+ <span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.rootOption')}</span>
149
+ )}
150
+ </div>
151
+
152
+ <div className={cx('byline-form-tree-actions', styles.actions)}>
153
+ <Button
154
+ type="button"
155
+ size="xs"
156
+ variant="outlined"
157
+ intent="noeffect"
158
+ disabled={loading || busy}
159
+ onClick={() => setPickerOpen(true)}
160
+ >
161
+ {t('treeWidget.choose')}
162
+ </Button>
163
+ {!placed ? (
164
+ <button
165
+ type="button"
166
+ className={cx('byline-form-tree-link', styles.link)}
167
+ disabled={loading || busy}
168
+ onClick={() => place(null, null)}
169
+ >
170
+ {t('treeWidget.addToTree')}
171
+ </button>
172
+ ) : (
173
+ parent != null && (
174
+ <button
175
+ type="button"
176
+ className={cx('byline-form-tree-link', styles.link)}
177
+ disabled={busy}
178
+ onClick={() => place(null, null)}
179
+ >
180
+ {t('treeWidget.makeRoot')}
181
+ </button>
182
+ )
183
+ )}
184
+ </div>
185
+
186
+ {error != null && <p className={cx('byline-form-tree-error', styles.error)}>{error}</p>}
187
+
188
+ {targetDefinition != null && (
189
+ <RelationPicker
190
+ targetCollectionPath={collectionPath}
191
+ targetDefinition={targetDefinition}
192
+ displayField={useAsTitle}
193
+ isOpen={pickerOpen}
194
+ onSelect={handlePick}
195
+ onDismiss={() => setPickerOpen(false)}
196
+ />
197
+ )}
198
+
199
+ <span className={styles['sr-only']}>{t('treeWidget.srDescription')}</span>
200
+ </div>
201
+ )
202
+ }
package/src/react.ts CHANGED
@@ -80,6 +80,12 @@ export type {
80
80
  CollectionListParams,
81
81
  CollectionListResponse,
82
82
  GetCollectionDocumentsFn,
83
+ GetTreeAncestorsFn,
84
+ GetTreeParentFn,
85
+ PlaceTreeNodeFn,
86
+ PlaceTreeNodeInput,
87
+ RemoveFromTreeFn,
88
+ TreeAncestor,
83
89
  UploadedFileResult,
84
90
  UploadFieldFn,
85
91
  } from './fields/field-services-types.js'