@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.
- package/dist/fields/array/array-field.d.ts +12 -2
- package/dist/fields/array/array-field.js +46 -32
- package/dist/fields/blocks/blocks-field.js +1 -0
- package/dist/fields/draggable-context-menu.js +2 -1
- package/dist/fields/field-admin.d.ts +15 -0
- package/dist/fields/field-admin.js +11 -0
- package/dist/fields/field-admin.test.node.d.ts +8 -0
- package/dist/fields/field-renderer.d.ts +11 -2
- package/dist/fields/field-renderer.js +6 -3
- package/dist/fields/group/group-field.d.ts +18 -5
- package/dist/fields/group/group-field.js +5 -3
- package/dist/fields/sortable-item.d.ts +6 -0
- package/dist/fields/sortable-item.js +44 -25
- package/dist/forms/form-context.js +1 -1
- package/dist/forms/form-renderer.js +3 -1
- package/package.json +10 -10
- package/src/fields/array/array-field.tsx +74 -33
- package/src/fields/blocks/blocks-field.tsx +5 -0
- package/src/fields/draggable-context-menu.tsx +9 -1
- package/src/fields/field-admin.test.node.ts +49 -0
- package/src/fields/field-admin.ts +39 -0
- package/src/fields/field-renderer.tsx +14 -0
- package/src/fields/group/group-field.tsx +21 -5
- package/src/fields/sortable-item.tsx +115 -34
- package/src/forms/form-context.tsx +9 -1
- package/src/forms/form-renderer.tsx +2 -0
|
@@ -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
|
|
144
|
-
if (disableSorting) return /*#__PURE__*/ jsx(
|
|
145
|
-
|
|
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:
|
|
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
|
-
|
|
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__*/
|
|
200
|
+
disableSorting ? /*#__PURE__*/ jsxs("div", {
|
|
170
201
|
className: classnames('byline-field-array-stack', array_field_module.stack),
|
|
171
|
-
children:
|
|
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
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
12
|
+
ref: rootRef,
|
|
25
13
|
style: style,
|
|
26
|
-
className: classnames('byline-sortable', sortable_item_module.root,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
168
|
+
"react-diff-viewer-continued": "^4.4.0",
|
|
169
169
|
"uuid": "^14.0.1",
|
|
170
170
|
"zod": "^4.4.3",
|
|
171
|
-
"@byline/core": "4.
|
|
172
|
-
"@byline/
|
|
173
|
-
"@byline/
|
|
174
|
-
"@byline/ui": "4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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={
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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={
|
|
65
|
+
ref={rootRef}
|
|
51
66
|
style={style}
|
|
52
67
|
className={cx(
|
|
53
68
|
'byline-sortable',
|
|
54
69
|
styles.root,
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|