@byline/admin 4.2.0 → 4.3.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.
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { ArrayField as ArrayFieldType } from '@byline/core';
9
- export declare const ArrayField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, }: {
8
+ import type { ArrayField as ArrayFieldType, FieldAdminConfig } from '@byline/core';
9
+ export declare const ArrayField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: {
10
10
  field: ArrayFieldType;
11
11
  defaultValue: any;
12
12
  path: string;
@@ -24,4 +24,14 @@ export declare const ArrayField: ({ field, defaultValue, path, disableSorting, c
24
24
  * can render their locale badge.
25
25
  */
26
26
  contentLocale?: string;
27
+ /**
28
+ * Admin overrides for the array's child fields, keyed by dotted,
29
+ * index-free schema paths relative to this array ('answer',
30
+ * 'filesGroup.publicationFile'). Schema paths address declarations, so
31
+ * one entry applies to that field in every item. Arrives pre-sliced from
32
+ * the enclosing widget (`FieldRenderer` / `GroupField`); exact-name
33
+ * entries apply to the child, deeper entries are re-sliced and threaded
34
+ * on (see `sliceFieldAdmin`).
35
+ */
36
+ fieldAdmin?: Record<string, FieldAdminConfig>;
27
37
  }) => import("react").JSX.Element;
@@ -3,12 +3,13 @@ import { useEffect, useState } from "react";
3
3
  import { useTranslation } from "@byline/i18n/react";
4
4
  import { DraggableSortable, IconButton, PlusIcon, moveItem } from "@byline/ui/react";
5
5
  import classnames from "classnames";
6
+ import { sliceFieldAdmin } from "../field-admin.js";
6
7
  import { defaultScalarForField } from "../field-helpers.js";
7
8
  import { FieldRenderer } from "../field-renderer.js";
8
- import { SortableItem } from "../sortable-item.js";
9
+ import { SortableItem, StaticItem } from "../sortable-item.js";
9
10
  import { useFormContext } from "../../forms/form-context.js";
10
11
  import array_field_module from "./array-field.module.js";
11
- const ArrayField = ({ field, defaultValue, path, disableSorting = false, collectionPath, contentLocale })=>{
12
+ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collectionPath, contentLocale, fieldAdmin })=>{
12
13
  const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext();
13
14
  const { t } = useTranslation('byline-admin');
14
15
  const [items, setItems] = useState([]);
@@ -113,6 +114,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
113
114
  const initial = item[childField.name];
114
115
  if ('group' === childField.type && childField.fields && childField.fields.length > 0) {
115
116
  const groupData = initial && 'object' == typeof initial ? initial : {};
117
+ const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name);
116
118
  return /*#__PURE__*/ jsxs("div", {
117
119
  className: classnames('byline-field-array-group-fields', array_field_module["group-fields"]),
118
120
  children: [
@@ -126,7 +128,10 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
126
128
  basePath: `${arrayElementPath}.${childField.name}`,
127
129
  disableSorting: true,
128
130
  collectionPath: collectionPath,
129
- contentLocale: contentLocale
131
+ contentLocale: contentLocale,
132
+ components: groupAdmin?.[innerField.name]?.components,
133
+ editor: groupAdmin?.[innerField.name]?.editor,
134
+ fieldAdmin: sliceFieldAdmin(groupAdmin, innerField.name)
130
135
  }, innerField.name))
131
136
  ]
132
137
  }, childField.name);
@@ -137,12 +142,17 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
137
142
  basePath: arrayElementPath,
138
143
  disableSorting: true,
139
144
  collectionPath: collectionPath,
140
- contentLocale: contentLocale
145
+ contentLocale: contentLocale,
146
+ components: fieldAdmin?.[childField.name]?.components,
147
+ editor: fieldAdmin?.[childField.name]?.editor,
148
+ fieldAdmin: sliceFieldAdmin(fieldAdmin, childField.name)
141
149
  }, childField.name);
142
150
  });
143
- const label = field.label ?? field.name;
144
- if (disableSorting) return /*#__PURE__*/ jsx("div", {
145
- className: classnames('byline-field-array-card', array_field_module.card),
151
+ const itemLabel = `${field.label ?? field.name} ${index + 1}`;
152
+ if (disableSorting) return /*#__PURE__*/ jsx(StaticItem, {
153
+ label: itemLabel,
154
+ onAddBelow: ()=>handleInsertBelow(index),
155
+ onRemove: ()=>handleRemoveItem(index),
146
156
  children: /*#__PURE__*/ jsx("div", {
147
157
  className: classnames('byline-field-array-group-fields', array_field_module["group-fields"]),
148
158
  children: innerBody
@@ -150,7 +160,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
150
160
  }, itemWrapper.id);
151
161
  return /*#__PURE__*/ jsx(SortableItem, {
152
162
  id: itemWrapper.id,
153
- label: label,
163
+ label: itemLabel,
154
164
  onAddBelow: ()=>handleInsertBelow(index),
155
165
  onRemove: ()=>handleRemoveItem(index),
156
166
  children: /*#__PURE__*/ jsx("div", {
@@ -159,43 +169,47 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
159
169
  })
160
170
  }, itemWrapper.id);
161
171
  };
172
+ const addRow = /*#__PURE__*/ jsxs("div", {
173
+ className: classnames('byline-field-array-add-row', array_field_module["add-row"]),
174
+ children: [
175
+ /*#__PURE__*/ jsx(IconButton, {
176
+ onClick: ()=>{
177
+ handleAddItem();
178
+ },
179
+ "aria-label": t('fields.array.addItemAriaLabel'),
180
+ children: /*#__PURE__*/ jsx(PlusIcon, {})
181
+ }),
182
+ /*#__PURE__*/ jsx("button", {
183
+ type: "button",
184
+ tabIndex: -1,
185
+ onClick: ()=>{
186
+ handleAddItem();
187
+ },
188
+ className: classnames('byline-field-array-add-label', array_field_module["add-label"]),
189
+ children: t('fields.array.addItem')
190
+ })
191
+ ]
192
+ });
162
193
  return /*#__PURE__*/ jsxs("div", {
163
194
  className: `byline-field-array ${field.name}`,
164
195
  children: [
165
- !disableSorting && field.label && /*#__PURE__*/ jsx("h3", {
196
+ field.label && /*#__PURE__*/ jsx("h3", {
166
197
  className: classnames('byline-field-array-title', array_field_module.title),
167
198
  children: field.label
168
199
  }),
169
- disableSorting ? /*#__PURE__*/ jsx("div", {
200
+ disableSorting ? /*#__PURE__*/ jsxs("div", {
170
201
  className: classnames('byline-field-array-stack', array_field_module.stack),
171
- children: items.map((item, index)=>renderItem(item, index))
202
+ children: [
203
+ items.map((item, index)=>renderItem(item, index)),
204
+ addRow
205
+ ]
172
206
  }) : /*#__PURE__*/ jsxs(DraggableSortable, {
173
207
  ids: items.map((i)=>i.id),
174
208
  onDragEnd: handleDragEnd,
175
209
  className: classnames('byline-field-array-stack', array_field_module.stack),
176
210
  children: [
177
211
  items.map((item, index)=>renderItem(item, index)),
178
- /*#__PURE__*/ jsxs("div", {
179
- className: classnames('byline-field-array-add-row', array_field_module["add-row"]),
180
- children: [
181
- /*#__PURE__*/ jsx(IconButton, {
182
- onClick: ()=>{
183
- handleAddItem();
184
- },
185
- "aria-label": t('fields.array.addItemAriaLabel'),
186
- children: /*#__PURE__*/ jsx(PlusIcon, {})
187
- }),
188
- /*#__PURE__*/ jsx("button", {
189
- type: "button",
190
- tabIndex: -1,
191
- onClick: ()=>{
192
- handleAddItem();
193
- },
194
- className: classnames('byline-field-array-add-label', array_field_module["add-label"]),
195
- children: t('fields.array.addItem')
196
- })
197
- ]
198
- })
212
+ addRow
199
213
  ]
200
214
  })
201
215
  ]
@@ -136,6 +136,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
136
136
  },
137
137
  defaultValue: fieldData,
138
138
  path: arrayElementPath,
139
+ disableSorting: false,
139
140
  contentLocale: contentLocale,
140
141
  fieldAdmin: blockAdminByType.get(item._type)?.fields
141
142
  }, subField.blockType);
@@ -16,7 +16,8 @@ function DraggableContextMenu({ onAddBelow, onRemove }) {
16
16
  /*#__PURE__*/ jsx(Dropdown.Trigger, {
17
17
  render: /*#__PURE__*/ jsx(IconButton, {
18
18
  variant: "text",
19
- size: "sm"
19
+ size: "sm",
20
+ "aria-label": t('fields.draggableMenu.triggerAriaLabel')
20
21
  }),
21
22
  children: /*#__PURE__*/ jsx(EllipsisIcon, {
22
23
  width: "16px",
@@ -0,0 +1,15 @@
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 { FieldAdminConfig } from '@byline/core';
9
+ /**
10
+ * Entries of `map` addressing descendants of `childName`, re-keyed with the
11
+ * `childName.` prefix stripped — the sub-map a structural child (group /
12
+ * array) threads to its own children. Returns `undefined` when `map` has no
13
+ * descendant entries for the child, so leaf widgets aren't handed empty maps.
14
+ */
15
+ export declare function sliceFieldAdmin(map: Record<string, FieldAdminConfig> | undefined, childName: string): Record<string, FieldAdminConfig> | undefined;
@@ -0,0 +1,11 @@
1
+ function sliceFieldAdmin(map, childName) {
2
+ if (null == map) return;
3
+ const prefix = `${childName}.`;
4
+ let sliced;
5
+ for (const [key, value] of Object.entries(map))if (key.startsWith(prefix)) {
6
+ sliced ??= {};
7
+ sliced[key.slice(prefix.length)] = value;
8
+ }
9
+ return sliced;
10
+ }
11
+ export { sliceFieldAdmin };
@@ -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 {};
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { Field, FieldComponentSlots, RichTextEditorComponent } from '@byline/core';
8
+ import type { Field, FieldAdminConfig, FieldComponentSlots, RichTextEditorComponent } from '@byline/core';
9
9
  interface FieldRendererProps {
10
10
  field: Field;
11
11
  defaultValue?: any;
@@ -32,6 +32,15 @@ interface FieldRendererProps {
32
32
  * Ignored when `field.type !== 'richText'`.
33
33
  */
34
34
  editor?: RichTextEditorComponent;
35
+ /**
36
+ * Admin overrides for this field's *descendants*, keyed by dotted,
37
+ * index-free schema paths relative to this field ('answer',
38
+ * 'filesGroup.publicationFile'). Only meaningful when `field` is a
39
+ * structural `group` / `array` — the widget slices the map per child
40
+ * (see `sliceFieldAdmin`). `components` / `editor` above stay the
41
+ * overrides for this field itself.
42
+ */
43
+ fieldAdmin?: Record<string, FieldAdminConfig>;
35
44
  }
36
- export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, }: FieldRendererProps) => import("react").JSX.Element | null;
45
+ export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
37
46
  export {};
@@ -20,7 +20,7 @@ import { TextField } from "./text/text-field.js";
20
20
  import { TextAreaField } from "./text-area/text-area-field.js";
21
21
  import { useFieldChangeHandler } from "./use-field-change-handler.js";
22
22
  import { useFieldCondition } from "./use-field-condition.js";
23
- const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor })=>{
23
+ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, fieldAdmin })=>{
24
24
  const path = basePath ? `${basePath}.${field.name}` : field.name;
25
25
  const htmlId = path.replace(/[[\].]/g, '-');
26
26
  const handleChange = useFieldChangeHandler(field, path);
@@ -203,8 +203,10 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
203
203
  } : field,
204
204
  defaultValue: defaultValue,
205
205
  path: path,
206
+ disableSorting: disableSorting,
206
207
  collectionPath: collectionPath,
207
- contentLocale: contentLocale
208
+ contentLocale: contentLocale,
209
+ fieldAdmin: fieldAdmin
208
210
  });
209
211
  case 'blocks':
210
212
  if (!field.blocks) return null;
@@ -222,7 +224,8 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
222
224
  path: path,
223
225
  disableSorting: disableSorting,
224
226
  collectionPath: collectionPath,
225
- contentLocale: contentLocale
227
+ contentLocale: contentLocale,
228
+ fieldAdmin: fieldAdmin
226
229
  });
227
230
  default:
228
231
  return null;
@@ -10,6 +10,16 @@ interface GroupFieldProps {
10
10
  field: GroupFieldType;
11
11
  defaultValue: any;
12
12
  path: string;
13
+ /**
14
+ * Threaded to child fields — governs only the *drag* affordance of any
15
+ * `array` children (structural add/remove always renders; see ArrayField).
16
+ * Defaults to `true` (conservative): arrays inside plain schema groups
17
+ * stay drag-free. `BlocksField` passes `false` on its synthesized group so
18
+ * arrays directly inside blocks are fully sortable — safe because each
19
+ * `DraggableSortable` is an independent DndContext with grip-scoped
20
+ * listeners.
21
+ */
22
+ disableSorting?: boolean;
13
23
  /**
14
24
  * Collection path forwarded to upload-capable child fields (`file` / `image`),
15
25
  * which need it to reach the `/upload` endpoint. Without it those fields fall
@@ -24,12 +34,15 @@ interface GroupFieldProps {
24
34
  contentLocale?: string;
25
35
  /**
26
36
  * Per-child-field admin overrides (`components` slots, richtext `editor`),
27
- * keyed by child field name. Threaded by `BlocksField` from the site-wide
28
- * `ClientConfig.blockAdmin` registry so block children can take per-field
29
- * admin config; plain groups receive none today (their children inherit
30
- * site-wide defaults).
37
+ * keyed by dotted, index-free schema paths relative to this group
38
+ * ('caption', 'faq.answer'). Threaded by `BlocksField` from the site-wide
39
+ * `ClientConfig.blockAdmin` registry (block children render through a
40
+ * synthesized group) and by `FieldRenderer` for plain schema groups, whose
41
+ * map arrives pre-sliced from the collection admin config. Exact-name
42
+ * entries apply to the child itself; deeper entries are re-sliced and
43
+ * threaded on (see `sliceFieldAdmin`).
31
44
  */
32
45
  fieldAdmin?: Record<string, FieldAdminConfig>;
33
46
  }
34
- export declare const GroupField: ({ field, defaultValue, path, collectionPath, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
47
+ export declare const GroupField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
35
48
  export {};
@@ -2,11 +2,12 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { ErrorText } from "@byline/ui/react";
4
4
  import classnames from "classnames";
5
+ import { sliceFieldAdmin } from "../field-admin.js";
5
6
  import { placeholderForField } from "../field-helpers.js";
6
7
  import { FieldRenderer } from "../field-renderer.js";
7
8
  import { useFieldError } from "../../forms/form-context.js";
8
9
  import group_field_module from "./group-field.module.js";
9
- const GroupField = ({ field, defaultValue, path, collectionPath, contentLocale, fieldAdmin })=>{
10
+ const GroupField = ({ field, defaultValue, path, disableSorting = true, collectionPath, contentLocale, fieldAdmin })=>{
10
11
  const fieldError = useFieldError(field.name);
11
12
  const groupData = useMemo(()=>{
12
13
  if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
@@ -46,11 +47,12 @@ const GroupField = ({ field, defaultValue, path, collectionPath, contentLocale,
46
47
  field: innerField,
47
48
  defaultValue: groupData[innerField.name],
48
49
  basePath: path,
49
- disableSorting: true,
50
+ disableSorting: disableSorting,
50
51
  collectionPath: collectionPath,
51
52
  contentLocale: contentLocale,
52
53
  components: fieldAdmin?.[innerField.name]?.components,
53
- editor: fieldAdmin?.[innerField.name]?.editor
54
+ editor: fieldAdmin?.[innerField.name]?.editor,
55
+ fieldAdmin: sliceFieldAdmin(fieldAdmin, innerField.name)
54
56
  }, innerField.name))
55
57
  }),
56
58
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
@@ -13,3 +13,9 @@ export declare const SortableItem: ({ id, label, children, onAddBelow, onRemove,
13
13
  onAddBelow?: () => void;
14
14
  onRemove?: () => void;
15
15
  }) => import("react").JSX.Element;
16
+ export declare const StaticItem: ({ label, children, onAddBelow, onRemove, }: {
17
+ label: ReactNode;
18
+ children: ReactNode;
19
+ onAddBelow?: () => void;
20
+ onRemove?: () => void;
21
+ }) => import("react").JSX.Element;
@@ -5,25 +5,13 @@ import { ChevronDownIcon, GripperVerticalIcon, useSortable } from "@byline/ui/re
5
5
  import classnames from "classnames";
6
6
  import { DraggableContextMenu } from "./draggable-context-menu.js";
7
7
  import sortable_item_module from "./sortable-item.module.js";
8
- const SortableItem = ({ id, label, children, onAddBelow, onRemove })=>{
8
+ const ItemFrame = ({ label, children, onAddBelow, onRemove, grip, rootRef, style, dragging = false, rootClassName })=>{
9
9
  const { t } = useTranslation('byline-admin');
10
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
11
- id,
12
- transition: {
13
- duration: 250,
14
- easing: 'cubic-bezier(0, 0.2, 0.2, 1)'
15
- }
16
- });
17
10
  const [collapsed, setCollapsed] = useState(false);
18
- const style = {
19
- transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : void 0,
20
- transition,
21
- zIndex: isDragging ? 10 : 'auto'
22
- };
23
11
  return /*#__PURE__*/ jsxs("div", {
24
- ref: setNodeRef,
12
+ ref: rootRef,
25
13
  style: style,
26
- className: classnames('byline-sortable', sortable_item_module.root, isDragging && [
14
+ className: classnames('byline-sortable', sortable_item_module.root, rootClassName, dragging && [
27
15
  'byline-sortable-dragging',
28
16
  sortable_item_module.dragging
29
17
  ], collapsed && [
@@ -37,15 +25,7 @@ const SortableItem = ({ id, label, children, onAddBelow, onRemove })=>{
37
25
  sortable_item_module["header-expanded"]
38
26
  ]),
39
27
  children: [
40
- /*#__PURE__*/ jsx("button", {
41
- type: "button",
42
- className: classnames('byline-sortable-grip', sortable_item_module.grip),
43
- ...attributes,
44
- ...listeners,
45
- children: /*#__PURE__*/ jsx(GripperVerticalIcon, {
46
- className: classnames('byline-sortable-grip-icon', sortable_item_module["grip-icon"])
47
- })
48
- }),
28
+ grip,
49
29
  /*#__PURE__*/ jsx("div", {
50
30
  className: classnames('byline-sortable-label', sortable_item_module.label),
51
31
  children: label
@@ -78,4 +58,43 @@ const SortableItem = ({ id, label, children, onAddBelow, onRemove })=>{
78
58
  ]
79
59
  });
80
60
  };
81
- export { SortableItem };
61
+ const SortableItem = ({ id, label, children, onAddBelow, onRemove })=>{
62
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
63
+ id,
64
+ transition: {
65
+ duration: 250,
66
+ easing: 'cubic-bezier(0, 0.2, 0.2, 1)'
67
+ }
68
+ });
69
+ const style = {
70
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : void 0,
71
+ transition,
72
+ zIndex: isDragging ? 10 : 'auto'
73
+ };
74
+ return /*#__PURE__*/ jsx(ItemFrame, {
75
+ label: label,
76
+ onAddBelow: onAddBelow,
77
+ onRemove: onRemove,
78
+ rootRef: setNodeRef,
79
+ style: style,
80
+ dragging: isDragging,
81
+ grip: /*#__PURE__*/ jsx("button", {
82
+ type: "button",
83
+ className: classnames('byline-sortable-grip', sortable_item_module.grip),
84
+ ...attributes,
85
+ ...listeners,
86
+ children: /*#__PURE__*/ jsx(GripperVerticalIcon, {
87
+ className: classnames('byline-sortable-grip-icon', sortable_item_module["grip-icon"])
88
+ })
89
+ }),
90
+ children: children
91
+ });
92
+ };
93
+ const StaticItem = ({ label, children, onAddBelow, onRemove })=>/*#__PURE__*/ jsx(ItemFrame, {
94
+ label: label,
95
+ onAddBelow: onAddBelow,
96
+ onRemove: onRemove,
97
+ rootClassName: "byline-sortable-static",
98
+ children: children
99
+ });
100
+ export { SortableItem, StaticItem };
@@ -140,7 +140,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
140
140
  const appendPatch = useCallback((patch)=>{
141
141
  patchesRef.current = [
142
142
  ...patchesRef.current,
143
- patch
143
+ structuredClone(patch)
144
144
  ];
145
145
  dirtyFields.current.add('__patch__');
146
146
  notifyMetaListeners();
@@ -5,6 +5,7 @@ import { getClientConfig } from "@byline/core";
5
5
  import { useTranslation } from "@byline/i18n/react";
6
6
  import { Alert, Button, ComboButton } from "@byline/ui/react";
7
7
  import classnames from "classnames";
8
+ import { sliceFieldAdmin } from "../fields/field-admin.js";
8
9
  import { FieldRenderer } from "../fields/field-renderer.js";
9
10
  import { useBylineFieldServices } from "../fields/field-services-context.js";
10
11
  import { AdminGroup } from "../presentation/group.js";
@@ -185,7 +186,8 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
185
186
  collectionPath: collectionPath,
186
187
  contentLocale: contentLocale,
187
188
  components: adminConfig?.fields?.[field.name]?.components,
188
- editor: adminConfig?.fields?.[field.name]?.editor
189
+ editor: adminConfig?.fields?.[field.name]?.editor,
190
+ fieldAdmin: sliceFieldAdmin(adminConfig?.fields, field.name)
189
191
  }, field.name);
190
192
  };
191
193
  const renderItem = (name)=>{
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": "4.2.0",
5
+ "version": "4.3.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -162,32 +162,32 @@
162
162
  "@codemirror/state": "^6.7.1",
163
163
  "@codemirror/view": "^6.43.6",
164
164
  "@lezer/highlight": "^1.2.3",
165
- "@tanstack/react-form-start": "^1.33.0",
165
+ "@tanstack/react-form-start": "^1.33.2",
166
166
  "classnames": "^2.5.1",
167
167
  "jose": "^6.2.3",
168
- "react-diff-viewer-continued": "^4.2.2",
168
+ "react-diff-viewer-continued": "^4.4.0",
169
169
  "uuid": "^14.0.1",
170
170
  "zod": "^4.4.3",
171
- "@byline/core": "4.2.0",
172
- "@byline/i18n": "4.2.0",
173
- "@byline/auth": "4.2.0",
174
- "@byline/ui": "4.2.0"
171
+ "@byline/core": "4.3.0",
172
+ "@byline/auth": "4.3.0",
173
+ "@byline/i18n": "4.3.0",
174
+ "@byline/ui": "4.3.0"
175
175
  },
176
176
  "peerDependencies": {
177
177
  "react": "^19.0.0",
178
178
  "react-dom": "^19.0.0"
179
179
  },
180
180
  "devDependencies": {
181
- "@biomejs/biome": "2.5.2",
181
+ "@biomejs/biome": "2.5.4",
182
182
  "@rsbuild/plugin-react": "^2.1.0",
183
183
  "@rslib/core": "^0.23.2",
184
- "@types/node": "^26.1.0",
184
+ "@types/node": "^26.1.1",
185
185
  "@types/react": "19.2.17",
186
186
  "@types/react-dom": "19.2.3",
187
187
  "react": "^19.2.7",
188
188
  "react-dom": "^19.2.7",
189
189
  "rimraf": "^6.1.3",
190
- "tsx": "^4.23.0",
190
+ "tsx": "^4.23.1",
191
191
  "typescript": "^7.0.2",
192
192
  "typescript-plugin-css-modules": "^5.2.0",
193
193
  "vitest": "^4.1.10"
@@ -8,20 +8,22 @@
8
8
 
9
9
  import { useEffect, useState } from 'react'
10
10
 
11
- import type { ArrayField as ArrayFieldType, Field } from '@byline/core'
11
+ import type { ArrayField as ArrayFieldType, Field, FieldAdminConfig } from '@byline/core'
12
12
  import { useTranslation } from '@byline/i18n/react'
13
13
  import { DraggableSortable, IconButton, moveItem, PlusIcon } from '@byline/ui/react'
14
14
  import cx from 'classnames'
15
15
 
16
+ import { sliceFieldAdmin } from '../../fields/field-admin'
16
17
  import { defaultScalarForField } from '../../fields/field-helpers'
17
18
  import { FieldRenderer } from '../../fields/field-renderer'
18
- import { SortableItem } from '../../fields/sortable-item'
19
+ import { SortableItem, StaticItem } from '../../fields/sortable-item'
19
20
  import { useFormContext } from '../../forms/form-context'
20
21
  import styles from './array-field.module.css'
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
- // ArrayField — renders `type: 'array'` fields. Children are homogeneous:
24
- // either single value fields or a single group definition. Supports D&D.
24
+ // ArrayField — renders `type: 'array'` fields. Each item renders every child
25
+ // field declared in the array's `fields` (value fields and/or groups, in any
26
+ // combination). Supports D&D.
25
27
  // ---------------------------------------------------------------------------
26
28
 
27
29
  export const ArrayField = ({
@@ -31,6 +33,7 @@ export const ArrayField = ({
31
33
  disableSorting = false,
32
34
  collectionPath,
33
35
  contentLocale,
36
+ fieldAdmin,
34
37
  }: {
35
38
  field: ArrayFieldType
36
39
  defaultValue: any
@@ -49,6 +52,16 @@ export const ArrayField = ({
49
52
  * can render their locale badge.
50
53
  */
51
54
  contentLocale?: string
55
+ /**
56
+ * Admin overrides for the array's child fields, keyed by dotted,
57
+ * index-free schema paths relative to this array ('answer',
58
+ * 'filesGroup.publicationFile'). Schema paths address declarations, so
59
+ * one entry applies to that field in every item. Arrives pre-sliced from
60
+ * the enclosing widget (`FieldRenderer` / `GroupField`); exact-name
61
+ * entries apply to the child, deeper entries are re-sliced and threaded
62
+ * on (see `sliceFieldAdmin`).
63
+ */
64
+ fieldAdmin?: Record<string, FieldAdminConfig>
52
65
  }) => {
53
66
  const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
54
67
  const { t } = useTranslation('byline-admin')
@@ -207,8 +220,11 @@ export const ArrayField = ({
207
220
  const initial = item[childField.name]
208
221
 
209
222
  if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
210
- // Group child — render its inner fields with the group's sub-object
223
+ // Group child — render its inner fields with the group's sub-object.
224
+ // fieldAdmin slices one level per structural hop: the group's
225
+ // descendant map first, then each inner field's own slice.
211
226
  const groupData = initial && typeof initial === 'object' ? initial : {}
227
+ const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name)
212
228
  return (
213
229
  <div
214
230
  key={childField.name}
@@ -228,6 +244,9 @@ export const ArrayField = ({
228
244
  disableSorting={true}
229
245
  collectionPath={collectionPath}
230
246
  contentLocale={contentLocale}
247
+ components={groupAdmin?.[innerField.name]?.components}
248
+ editor={groupAdmin?.[innerField.name]?.editor}
249
+ fieldAdmin={sliceFieldAdmin(groupAdmin, innerField.name)}
231
250
  />
232
251
  ))}
233
252
  </div>
@@ -243,19 +262,34 @@ export const ArrayField = ({
243
262
  disableSorting={true}
244
263
  collectionPath={collectionPath}
245
264
  contentLocale={contentLocale}
265
+ components={fieldAdmin?.[childField.name]?.components}
266
+ editor={fieldAdmin?.[childField.name]?.editor}
267
+ fieldAdmin={sliceFieldAdmin(fieldAdmin, childField.name)}
246
268
  />
247
269
  )
248
270
  })
249
271
 
250
- const label = field.label ?? field.name
272
+ // Per-item label: the array label plus a 1-based position, so items
273
+ // read "Questions 1", "Questions 2" rather than all repeating the bare
274
+ // array label.
275
+ const itemLabel = `${field.label ?? field.name} ${index + 1}`
251
276
 
277
+ // `disableSorting` removes only the drag affordance. Structural editing
278
+ // (add-below / remove / collapse) is always available — StaticItem is
279
+ // the grip-less sibling of SortableItem (see sortable-item.tsx for why
280
+ // it is a separate component rather than a prop).
252
281
  if (disableSorting) {
253
282
  return (
254
- <div key={itemWrapper.id} className={cx('byline-field-array-card', styles.card)}>
283
+ <StaticItem
284
+ key={itemWrapper.id}
285
+ label={itemLabel}
286
+ onAddBelow={() => handleInsertBelow(index)}
287
+ onRemove={() => handleRemoveItem(index)}
288
+ >
255
289
  <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
256
290
  {innerBody}
257
291
  </div>
258
- </div>
292
+ </StaticItem>
259
293
  )
260
294
  }
261
295
 
@@ -263,7 +297,7 @@ export const ArrayField = ({
263
297
  <SortableItem
264
298
  key={itemWrapper.id}
265
299
  id={itemWrapper.id}
266
- label={label}
300
+ label={itemLabel}
267
301
  onAddBelow={() => handleInsertBelow(index)}
268
302
  onRemove={() => handleRemoveItem(index)}
269
303
  >
@@ -274,14 +308,43 @@ export const ArrayField = ({
274
308
  )
275
309
  }
276
310
 
311
+ // The add-row renders in both branches — structural editing does not
312
+ // depend on sortability.
313
+ const addRow = (
314
+ <div className={cx('byline-field-array-add-row', styles['add-row'])}>
315
+ <IconButton
316
+ onClick={() => {
317
+ void handleAddItem()
318
+ }}
319
+ aria-label={t('fields.array.addItemAriaLabel')}
320
+ >
321
+ <PlusIcon />
322
+ </IconButton>
323
+ {/* Text-styled button so the label is clickable too. Removed from
324
+ the tab order (tabIndex -1) — the IconButton is the single
325
+ keyboard/screen-reader control for this action. */}
326
+ <button
327
+ type="button"
328
+ tabIndex={-1}
329
+ onClick={() => {
330
+ void handleAddItem()
331
+ }}
332
+ className={cx('byline-field-array-add-label', styles['add-label'])}
333
+ >
334
+ {t('fields.array.addItem')}
335
+ </button>
336
+ </div>
337
+ )
338
+
277
339
  return (
278
340
  <div className={`byline-field-array ${field.name}`}>
279
- {!disableSorting && field.label && (
341
+ {field.label && (
280
342
  <h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>
281
343
  )}
282
344
  {disableSorting ? (
283
345
  <div className={cx('byline-field-array-stack', styles.stack)}>
284
346
  {items.map((item, index) => renderItem(item, index))}
347
+ {addRow}
285
348
  </div>
286
349
  ) : (
287
350
  <DraggableSortable
@@ -290,29 +353,7 @@ export const ArrayField = ({
290
353
  className={cx('byline-field-array-stack', styles.stack)}
291
354
  >
292
355
  {items.map((item, index) => renderItem(item, index))}
293
- <div className={cx('byline-field-array-add-row', styles['add-row'])}>
294
- <IconButton
295
- onClick={() => {
296
- void handleAddItem()
297
- }}
298
- aria-label={t('fields.array.addItemAriaLabel')}
299
- >
300
- <PlusIcon />
301
- </IconButton>
302
- {/* Text-styled button so the label is clickable too. Removed from
303
- the tab order (tabIndex -1) — the IconButton is the single
304
- keyboard/screen-reader control for this action. */}
305
- <button
306
- type="button"
307
- tabIndex={-1}
308
- onClick={() => {
309
- void handleAddItem()
310
- }}
311
- className={cx('byline-field-array-add-label', styles['add-label'])}
312
- >
313
- {t('fields.array.addItem')}
314
- </button>
315
- </div>
356
+ {addRow}
316
357
  </DraggableSortable>
317
358
  )}
318
359
  </div>
@@ -249,6 +249,11 @@ export const BlocksField = ({
249
249
  }
250
250
  defaultValue={fieldData}
251
251
  path={arrayElementPath}
252
+ // Arrays directly inside a block are fully sortable: each
253
+ // DraggableSortable is an independent DndContext and drag listeners
254
+ // are grip-scoped, so the inner array's drags can't leak into the
255
+ // block-level context (and vice versa).
256
+ disableSorting={false}
252
257
  contentLocale={contentLocale}
253
258
  fieldAdmin={blockAdminByType.get(item._type)?.fields}
254
259
  />
@@ -37,7 +37,15 @@ export function DraggableContextMenu({
37
37
 
38
38
  return (
39
39
  <DropdownMenu.Root modal={false}>
40
- <DropdownMenu.Trigger render={<IconButton variant="text" size="sm" />}>
40
+ <DropdownMenu.Trigger
41
+ render={
42
+ <IconButton
43
+ variant="text"
44
+ size="sm"
45
+ aria-label={t('fields.draggableMenu.triggerAriaLabel')}
46
+ />
47
+ }
48
+ >
41
49
  <EllipsisIcon width="16px" height="16px" />
42
50
  </DropdownMenu.Trigger>
43
51
 
@@ -0,0 +1,49 @@
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 { sliceFieldAdmin } from './field-admin.js'
12
+
13
+ describe('sliceFieldAdmin', () => {
14
+ const answer = { editor: (() => null) as any }
15
+ const question = { components: {} }
16
+
17
+ it('returns undefined for an undefined map', () => {
18
+ expect(sliceFieldAdmin(undefined, 'faq')).toBeUndefined()
19
+ })
20
+
21
+ it('returns undefined when no entry addresses a descendant of the child', () => {
22
+ expect(sliceFieldAdmin({ faq: question, other: question }, 'faq')).toBeUndefined()
23
+ })
24
+
25
+ it('strips the child prefix from descendant keys', () => {
26
+ expect(sliceFieldAdmin({ 'faq.answer': answer, 'faq.question': question }, 'faq')).toEqual({
27
+ answer,
28
+ question,
29
+ })
30
+ })
31
+
32
+ it('keeps deeper paths dotted for the next level to slice', () => {
33
+ const map = { 'files.filesGroup.publicationFile': question }
34
+ const level1 = sliceFieldAdmin(map, 'files')
35
+ expect(level1).toEqual({ 'filesGroup.publicationFile': question })
36
+ expect(sliceFieldAdmin(level1, 'filesGroup')).toEqual({ publicationFile: question })
37
+ })
38
+
39
+ it('does not match on name prefixes that are not path segments', () => {
40
+ // 'faqExtra.answer' must not be sliced by child 'faq'.
41
+ expect(sliceFieldAdmin({ 'faqExtra.answer': answer }, 'faq')).toBeUndefined()
42
+ })
43
+
44
+ it('excludes the child’s own exact-name entry from the slice', () => {
45
+ expect(sliceFieldAdmin({ faq: question, 'faq.answer': answer }, 'faq')).toEqual({
46
+ answer,
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,39 @@
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 type { FieldAdminConfig } from '@byline/core'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Field admin map slicing — `fields{}` override maps (CollectionAdminConfig /
13
+ // BlockAdminConfig) are keyed by dotted, index-free schema paths relative to
14
+ // the map's root ('title', 'faq.answer'). Structural widgets thread the map
15
+ // down one level at a time: a child field takes its own entry by exact name,
16
+ // and receives the descendant entries re-keyed relative to itself.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Entries of `map` addressing descendants of `childName`, re-keyed with the
21
+ * `childName.` prefix stripped — the sub-map a structural child (group /
22
+ * array) threads to its own children. Returns `undefined` when `map` has no
23
+ * descendant entries for the child, so leaf widgets aren't handed empty maps.
24
+ */
25
+ export function sliceFieldAdmin(
26
+ map: Record<string, FieldAdminConfig> | undefined,
27
+ childName: string
28
+ ): Record<string, FieldAdminConfig> | undefined {
29
+ if (map == null) return undefined
30
+ const prefix = `${childName}.`
31
+ let sliced: Record<string, FieldAdminConfig> | undefined
32
+ for (const [key, value] of Object.entries(map)) {
33
+ if (key.startsWith(prefix)) {
34
+ sliced ??= {}
35
+ sliced[key.slice(prefix.length)] = value
36
+ }
37
+ }
38
+ return sliced
39
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  ArrayField as ArrayFieldType,
11
11
  BlocksField as BlocksFieldType,
12
12
  Field,
13
+ FieldAdminConfig,
13
14
  FieldComponentSlots,
14
15
  GroupField as GroupFieldType,
15
16
  RichTextEditorComponent,
@@ -68,6 +69,15 @@ interface FieldRendererProps {
68
69
  * Ignored when `field.type !== 'richText'`.
69
70
  */
70
71
  editor?: RichTextEditorComponent
72
+ /**
73
+ * Admin overrides for this field's *descendants*, keyed by dotted,
74
+ * index-free schema paths relative to this field ('answer',
75
+ * 'filesGroup.publicationFile'). Only meaningful when `field` is a
76
+ * structural `group` / `array` — the widget slices the map per child
77
+ * (see `sliceFieldAdmin`). `components` / `editor` above stay the
78
+ * overrides for this field itself.
79
+ */
80
+ fieldAdmin?: Record<string, FieldAdminConfig>
71
81
  }
72
82
 
73
83
  export const FieldRenderer = ({
@@ -80,6 +90,7 @@ export const FieldRenderer = ({
80
90
  contentLocale,
81
91
  components,
82
92
  editor,
93
+ fieldAdmin,
83
94
  }: FieldRendererProps) => {
84
95
  const path = basePath ? `${basePath}.${field.name}` : field.name
85
96
  const htmlId = path.replace(/[[\].]/g, '-')
@@ -289,8 +300,10 @@ export const FieldRenderer = ({
289
300
  }
290
301
  defaultValue={defaultValue}
291
302
  path={path}
303
+ disableSorting={disableSorting}
292
304
  collectionPath={collectionPath}
293
305
  contentLocale={contentLocale}
306
+ fieldAdmin={fieldAdmin}
294
307
  />
295
308
  )
296
309
  case 'blocks':
@@ -313,6 +326,7 @@ export const FieldRenderer = ({
313
326
  disableSorting={disableSorting}
314
327
  collectionPath={collectionPath}
315
328
  contentLocale={contentLocale}
329
+ fieldAdmin={fieldAdmin}
316
330
  />
317
331
  )
318
332
  default:
@@ -12,6 +12,7 @@ import type { Field, FieldAdminConfig, GroupField as GroupFieldType } from '@byl
12
12
  import { ErrorText } from '@byline/ui/react'
13
13
  import cx from 'classnames'
14
14
 
15
+ import { sliceFieldAdmin } from '../../fields/field-admin'
15
16
  import { placeholderForField } from '../../fields/field-helpers'
16
17
  import { FieldRenderer } from '../../fields/field-renderer'
17
18
  import { useFieldError } from '../../forms/form-context'
@@ -32,6 +33,16 @@ interface GroupFieldProps {
32
33
  field: GroupFieldType
33
34
  defaultValue: any
34
35
  path: string
36
+ /**
37
+ * Threaded to child fields — governs only the *drag* affordance of any
38
+ * `array` children (structural add/remove always renders; see ArrayField).
39
+ * Defaults to `true` (conservative): arrays inside plain schema groups
40
+ * stay drag-free. `BlocksField` passes `false` on its synthesized group so
41
+ * arrays directly inside blocks are fully sortable — safe because each
42
+ * `DraggableSortable` is an independent DndContext with grip-scoped
43
+ * listeners.
44
+ */
45
+ disableSorting?: boolean
35
46
  /**
36
47
  * Collection path forwarded to upload-capable child fields (`file` / `image`),
37
48
  * which need it to reach the `/upload` endpoint. Without it those fields fall
@@ -46,10 +57,13 @@ interface GroupFieldProps {
46
57
  contentLocale?: string
47
58
  /**
48
59
  * Per-child-field admin overrides (`components` slots, richtext `editor`),
49
- * keyed by child field name. Threaded by `BlocksField` from the site-wide
50
- * `ClientConfig.blockAdmin` registry so block children can take per-field
51
- * admin config; plain groups receive none today (their children inherit
52
- * site-wide defaults).
60
+ * keyed by dotted, index-free schema paths relative to this group
61
+ * ('caption', 'faq.answer'). Threaded by `BlocksField` from the site-wide
62
+ * `ClientConfig.blockAdmin` registry (block children render through a
63
+ * synthesized group) and by `FieldRenderer` for plain schema groups, whose
64
+ * map arrives pre-sliced from the collection admin config. Exact-name
65
+ * entries apply to the child itself; deeper entries are re-sliced and
66
+ * threaded on (see `sliceFieldAdmin`).
53
67
  */
54
68
  fieldAdmin?: Record<string, FieldAdminConfig>
55
69
  }
@@ -58,6 +72,7 @@ export const GroupField = ({
58
72
  field,
59
73
  defaultValue,
60
74
  path,
75
+ disableSorting = true,
61
76
  collectionPath,
62
77
  contentLocale,
63
78
  fieldAdmin,
@@ -100,11 +115,12 @@ export const GroupField = ({
100
115
  field={innerField}
101
116
  defaultValue={groupData[innerField.name]}
102
117
  basePath={path}
103
- disableSorting={true}
118
+ disableSorting={disableSorting}
104
119
  collectionPath={collectionPath}
105
120
  contentLocale={contentLocale}
106
121
  components={fieldAdmin?.[innerField.name]?.components}
107
122
  editor={fieldAdmin?.[innerField.name]?.editor}
123
+ fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}
108
124
  />
109
125
  )
110
126
  })}
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { type ReactNode, useState } from 'react'
9
+ import { type CSSProperties, type ReactNode, type Ref, useState } from 'react'
10
10
 
11
11
  import { useTranslation } from '@byline/i18n/react'
12
12
  import { ChevronDownIcon, GripperVerticalIcon, useSortable } from '@byline/ui/react'
@@ -15,44 +15,60 @@ import cx from 'classnames'
15
15
  import { DraggableContextMenu } from './draggable-context-menu'
16
16
  import styles from './sortable-item.module.css'
17
17
 
18
- export const SortableItem = ({
19
- id,
20
- label,
21
- children,
22
- onAddBelow,
23
- onRemove,
24
- }: {
25
- id: string
18
+ // ---------------------------------------------------------------------------
19
+ // Item chrome for repeating-structure entries (array items, block instances):
20
+ // a header carrying the label, the add-below/remove context menu, and a
21
+ // collapse toggle, above the item's rendered fields.
22
+ //
23
+ // Two variants share the frame:
24
+ // - `SortableItem` — adds the dnd-kit grip (drag handle). Must render
25
+ // inside a `DraggableSortable` (it calls `useSortable`).
26
+ // - `StaticItem` — no grip, no dnd hook. For contexts where drag is
27
+ // disabled but structural editing (add/remove/collapse) must remain,
28
+ // e.g. arrays nested inside another array's items.
29
+ //
30
+ // They are separate components (not a boolean prop on one component)
31
+ // because `useSortable` is a hook — it cannot be called conditionally, and
32
+ // it throws outside a DndContext.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface ItemFrameProps {
26
36
  label: ReactNode
27
37
  children: ReactNode
28
38
  onAddBelow?: () => void
29
39
  onRemove?: () => void
30
- }) => {
31
- const { t } = useTranslation('byline-admin')
32
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
33
- id,
34
- transition: {
35
- duration: 250,
36
- easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
37
- },
38
- })
40
+ /** Drag handle slot — rendered leading the header when provided. */
41
+ grip?: ReactNode
42
+ rootRef?: Ref<HTMLDivElement>
43
+ style?: CSSProperties
44
+ dragging?: boolean
45
+ /** Extra root class, e.g. the static variant marker. */
46
+ rootClassName?: string
47
+ }
39
48
 
49
+ const ItemFrame = ({
50
+ label,
51
+ children,
52
+ onAddBelow,
53
+ onRemove,
54
+ grip,
55
+ rootRef,
56
+ style,
57
+ dragging = false,
58
+ rootClassName,
59
+ }: ItemFrameProps) => {
60
+ const { t } = useTranslation('byline-admin')
40
61
  const [collapsed, setCollapsed] = useState(false)
41
62
 
42
- const style = {
43
- transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
44
- transition,
45
- zIndex: isDragging ? 10 : 'auto',
46
- }
47
-
48
63
  return (
49
64
  <div
50
- ref={setNodeRef}
65
+ ref={rootRef}
51
66
  style={style}
52
67
  className={cx(
53
68
  'byline-sortable',
54
69
  styles.root,
55
- isDragging && ['byline-sortable-dragging', styles.dragging],
70
+ rootClassName,
71
+ dragging && ['byline-sortable-dragging', styles.dragging],
56
72
  collapsed && ['byline-sortable-collapsed', styles.collapsed]
57
73
  )}
58
74
  >
@@ -63,14 +79,7 @@ export const SortableItem = ({
63
79
  !collapsed && ['byline-sortable-header-expanded', styles['header-expanded']]
64
80
  )}
65
81
  >
66
- <button
67
- type="button"
68
- className={cx('byline-sortable-grip', styles.grip)}
69
- {...attributes}
70
- {...listeners}
71
- >
72
- <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
73
- </button>
82
+ {grip}
74
83
  <div className={cx('byline-sortable-label', styles.label)}>{label}</div>
75
84
  <DraggableContextMenu onAddBelow={onAddBelow} onRemove={onRemove} />
76
85
  <button
@@ -104,3 +113,75 @@ export const SortableItem = ({
104
113
  </div>
105
114
  )
106
115
  }
116
+
117
+ export const SortableItem = ({
118
+ id,
119
+ label,
120
+ children,
121
+ onAddBelow,
122
+ onRemove,
123
+ }: {
124
+ id: string
125
+ label: ReactNode
126
+ children: ReactNode
127
+ onAddBelow?: () => void
128
+ onRemove?: () => void
129
+ }) => {
130
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
131
+ id,
132
+ transition: {
133
+ duration: 250,
134
+ easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
135
+ },
136
+ })
137
+
138
+ const style = {
139
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
140
+ transition,
141
+ zIndex: isDragging ? 10 : 'auto',
142
+ }
143
+
144
+ return (
145
+ <ItemFrame
146
+ label={label}
147
+ onAddBelow={onAddBelow}
148
+ onRemove={onRemove}
149
+ rootRef={setNodeRef}
150
+ style={style}
151
+ dragging={isDragging}
152
+ grip={
153
+ <button
154
+ type="button"
155
+ className={cx('byline-sortable-grip', styles.grip)}
156
+ {...attributes}
157
+ {...listeners}
158
+ >
159
+ <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
160
+ </button>
161
+ }
162
+ >
163
+ {children}
164
+ </ItemFrame>
165
+ )
166
+ }
167
+
168
+ export const StaticItem = ({
169
+ label,
170
+ children,
171
+ onAddBelow,
172
+ onRemove,
173
+ }: {
174
+ label: ReactNode
175
+ children: ReactNode
176
+ onAddBelow?: () => void
177
+ onRemove?: () => void
178
+ }) => (
179
+ <ItemFrame
180
+ label={label}
181
+ onAddBelow={onAddBelow}
182
+ onRemove={onRemove}
183
+ rootClassName="byline-sortable-static"
184
+ >
185
+ {children}
186
+ </ItemFrame>
187
+ )
@@ -316,7 +316,15 @@ export const FormProvider = ({
316
316
  const getPatches = useCallback(() => patchesRef.current, [])
317
317
  const appendPatch = useCallback(
318
318
  (patch: DocumentPatch) => {
319
- patchesRef.current = [...patchesRef.current, patch]
319
+ // Snapshot the patch at append time. Structural patches (array.insert,
320
+ // block add) carry item objects that are ALSO placed into the form
321
+ // store — and `setNestedValue` mutates store nodes in place, so a
322
+ // later nested write inside the item (e.g. adding an array item to a
323
+ // block added this session) would silently rewrite the queued patch.
324
+ // Serialized at save time, the block insert would then already contain
325
+ // the array items AND the array.insert patches would re-add them —
326
+ // duplicating items server-side (caught by e2e/array-in-block.spec.ts).
327
+ patchesRef.current = [...patchesRef.current, structuredClone(patch)]
320
328
  // Mark a generic dirty flag so hasChanges() becomes true even
321
329
  // for patches that don't correspond to a specific field.set.
322
330
  dirtyFields.current.add('__patch__')
@@ -24,6 +24,7 @@ import { useTranslation } from '@byline/i18n/react'
24
24
  import { Alert, Button, ComboButton } from '@byline/ui/react'
25
25
  import cx from 'classnames'
26
26
 
27
+ import { sliceFieldAdmin } from '../fields/field-admin'
27
28
  import { FieldRenderer } from '../fields/field-renderer'
28
29
  import { useBylineFieldServices } from '../fields/field-services-context'
29
30
  import { AdminGroup } from '../presentation/group'
@@ -488,6 +489,7 @@ const FormContent = ({
488
489
  contentLocale={contentLocale}
489
490
  components={adminConfig?.fields?.[field.name]?.components}
490
491
  editor={adminConfig?.fields?.[field.name]?.editor}
492
+ fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
491
493
  />
492
494
  )
493
495
  }