@byline/admin 4.2.0 → 4.4.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 -9
- package/dist/fields/array/array-field.js +46 -34
- 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 -4
- package/dist/fields/field-renderer.js +8 -9
- package/dist/fields/file/file-field.d.ts +1 -3
- package/dist/fields/file/file-field.js +2 -2
- package/dist/fields/group/group-field.d.ts +16 -9
- package/dist/fields/group/group-field.js +5 -4
- package/dist/fields/image/image-field.d.ts +1 -3
- package/dist/fields/image/image-field.js +2 -2
- package/dist/fields/sortable-item.d.ts +6 -0
- package/dist/fields/sortable-item.js +44 -25
- package/dist/forms/form-context.d.ts +20 -1
- package/dist/forms/form-context.js +3 -2
- package/dist/forms/form-renderer.js +4 -2
- package/dist/forms/upload-executor.js +50 -29
- package/package.json +10 -10
- package/src/fields/array/array-field.tsx +74 -43
- 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 -7
- package/src/fields/file/file-field.tsx +4 -4
- package/src/fields/group/group-field.tsx +19 -11
- package/src/fields/image/image-field.tsx +4 -4
- package/src/fields/sortable-item.tsx +115 -34
- package/src/forms/form-context.tsx +30 -1
- package/src/forms/form-renderer.tsx +3 -1
- package/src/forms/upload-executor.test.node.ts +187 -0
- package/src/forms/upload-executor.ts +90 -29
|
@@ -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";
|
|
@@ -182,10 +183,10 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
182
183
|
return /*#__PURE__*/ jsx(FieldRenderer, {
|
|
183
184
|
field: field,
|
|
184
185
|
defaultValue: initialData?.fields?.[field.name],
|
|
185
|
-
collectionPath: collectionPath,
|
|
186
186
|
contentLocale: contentLocale,
|
|
187
187
|
components: adminConfig?.fields?.[field.name]?.components,
|
|
188
|
-
editor: adminConfig?.fields?.[field.name]?.editor
|
|
188
|
+
editor: adminConfig?.fields?.[field.name]?.editor,
|
|
189
|
+
fieldAdmin: sliceFieldAdmin(adminConfig?.fields, field.name)
|
|
189
190
|
}, field.name);
|
|
190
191
|
};
|
|
191
192
|
const renderItem = (name)=>{
|
|
@@ -403,6 +404,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
|
|
|
403
404
|
return /*#__PURE__*/ jsx(FormProvider, {
|
|
404
405
|
initialData: initialData,
|
|
405
406
|
documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
|
|
407
|
+
collectionPath: collectionPath ?? null,
|
|
406
408
|
children: /*#__PURE__*/ jsx(FormContent, {
|
|
407
409
|
mode: mode,
|
|
408
410
|
fields: fields,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseInstancePath } from "@byline/core";
|
|
1
2
|
import { get } from "./nested-path.js";
|
|
2
3
|
async function executeUploads(pendingUploads, uploadField, executionContext) {
|
|
3
4
|
const results = [];
|
|
@@ -36,16 +37,14 @@ function buildUploadFormData(fieldPath, upload, executionContext) {
|
|
|
36
37
|
formData.append('field', uploadFieldName(fieldPath));
|
|
37
38
|
formData.append('fieldPath', fieldPath);
|
|
38
39
|
if (executionContext?.documentId) formData.append('documentId', executionContext.documentId);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
|
|
48
|
-
}
|
|
40
|
+
const formValues = executionContext?.getFormValues?.();
|
|
41
|
+
const contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath, formValues)?.context : void 0;
|
|
42
|
+
if (contextPaths && contextPaths.length > 0 && formValues) for (const contextPath of contextPaths){
|
|
43
|
+
const resolvedPath = resolveContextPath(fieldPath, contextPath);
|
|
44
|
+
if (void 0 === resolvedPath) continue;
|
|
45
|
+
const value = '' === resolvedPath ? formValues : get(formValues, resolvedPath);
|
|
46
|
+
const serialized = serializeContextValue(value);
|
|
47
|
+
if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
|
|
49
48
|
}
|
|
50
49
|
return formData;
|
|
51
50
|
}
|
|
@@ -89,27 +88,49 @@ function serializeContextValue(value) {
|
|
|
89
88
|
function isRelationEnvelope(value) {
|
|
90
89
|
return 'object' == typeof value && null !== value && 'string' == typeof value.targetDocumentId;
|
|
91
90
|
}
|
|
92
|
-
function findUploadFieldByPath(fields, fieldPath) {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
function findUploadFieldByPath(fields, fieldPath, formValues) {
|
|
92
|
+
const parsed = parseInstancePath(fieldPath);
|
|
93
|
+
if (!parsed.ok) return;
|
|
94
|
+
return resolveUploadConfig(fields, parsed.segments, formValues);
|
|
95
|
+
}
|
|
96
|
+
function selectItem(value, segment) {
|
|
97
|
+
if (!Array.isArray(value)) return;
|
|
98
|
+
if ('index' === segment.kind) return value[segment.index];
|
|
99
|
+
if ('id' === segment.kind) return value.find((item)=>item?._id === segment.id);
|
|
100
|
+
}
|
|
101
|
+
function resolveUploadConfig(fields, segments, value) {
|
|
102
|
+
const head = segments[0];
|
|
103
|
+
if (null == head || 'field' !== head.kind) return;
|
|
104
|
+
const field = fields.find((candidate)=>candidate.name === head.name);
|
|
105
|
+
if (null == field) return;
|
|
106
|
+
const fieldValue = value?.[head.name];
|
|
107
|
+
const rest = segments.slice(1);
|
|
108
|
+
if (0 === rest.length) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
|
|
109
|
+
if ('group' === field.type) return resolveUploadConfig(field.fields, rest, fieldValue);
|
|
110
|
+
if ('array' === field.type) {
|
|
111
|
+
const selector = rest[0];
|
|
112
|
+
if (selector?.kind === 'index' || selector?.kind === 'id') return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector));
|
|
113
|
+
return resolveUploadConfig(field.fields, rest, void 0);
|
|
114
|
+
}
|
|
115
|
+
if ('blocks' === field.type) {
|
|
116
|
+
const selector = rest[0];
|
|
117
|
+
const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest;
|
|
118
|
+
const item = selector?.kind === 'index' || selector?.kind === 'id' ? selectItem(fieldValue, selector) : void 0;
|
|
119
|
+
const blockType = item?._type;
|
|
120
|
+
if ('string' == typeof blockType) {
|
|
121
|
+
const block = field.blocks.find((candidate)=>candidate.blockType === blockType);
|
|
122
|
+
return null == block ? void 0 : resolveUploadConfig(block.fields, remainder, item);
|
|
104
123
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
let found;
|
|
125
|
+
let matches = 0;
|
|
126
|
+
for (const block of field.blocks){
|
|
127
|
+
const result = resolveUploadConfig(block.fields, remainder, void 0);
|
|
128
|
+
if (void 0 !== result) {
|
|
129
|
+
matches += 1;
|
|
130
|
+
found = result;
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
|
-
|
|
133
|
+
return 1 === matches ? found : void 0;
|
|
113
134
|
}
|
|
114
135
|
}
|
|
115
136
|
async function executeUploadsWithProgress(pendingUploads, uploadField, onProgress, executionContext) {
|
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.4.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/
|
|
172
|
-
"@byline/i18n": "4.
|
|
173
|
-
"@byline/
|
|
174
|
-
"@byline/
|
|
171
|
+
"@byline/auth": "4.4.0",
|
|
172
|
+
"@byline/i18n": "4.4.0",
|
|
173
|
+
"@byline/ui": "4.4.0",
|
|
174
|
+
"@byline/core": "4.4.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 = ({
|
|
@@ -29,26 +31,29 @@ export const ArrayField = ({
|
|
|
29
31
|
defaultValue,
|
|
30
32
|
path,
|
|
31
33
|
disableSorting = false,
|
|
32
|
-
collectionPath,
|
|
33
34
|
contentLocale,
|
|
35
|
+
fieldAdmin,
|
|
34
36
|
}: {
|
|
35
37
|
field: ArrayFieldType
|
|
36
38
|
defaultValue: any
|
|
37
39
|
path: string
|
|
38
40
|
disableSorting?: boolean
|
|
39
|
-
/**
|
|
40
|
-
* Collection path forwarded to upload-capable fields (`file` / `image`)
|
|
41
|
-
* nested inside an array item, which need it to reach the `/upload`
|
|
42
|
-
* endpoint. Without it those fields fall back to their empty placeholder
|
|
43
|
-
* and never render an upload widget.
|
|
44
|
-
*/
|
|
45
|
-
collectionPath?: string
|
|
46
41
|
/**
|
|
47
42
|
* Active content locale, forwarded to each array item's fields so
|
|
48
43
|
* localized widgets nested inside an array (e.g. a `localized` richText)
|
|
49
44
|
* can render their locale badge.
|
|
50
45
|
*/
|
|
51
46
|
contentLocale?: string
|
|
47
|
+
/**
|
|
48
|
+
* Admin overrides for the array's child fields, keyed by dotted,
|
|
49
|
+
* index-free schema paths relative to this array ('answer',
|
|
50
|
+
* 'filesGroup.publicationFile'). Schema paths address declarations, so
|
|
51
|
+
* one entry applies to that field in every item. Arrives pre-sliced from
|
|
52
|
+
* the enclosing widget (`FieldRenderer` / `GroupField`); exact-name
|
|
53
|
+
* entries apply to the child, deeper entries are re-sliced and threaded
|
|
54
|
+
* on (see `sliceFieldAdmin`).
|
|
55
|
+
*/
|
|
56
|
+
fieldAdmin?: Record<string, FieldAdminConfig>
|
|
52
57
|
}) => {
|
|
53
58
|
const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
|
|
54
59
|
const { t } = useTranslation('byline-admin')
|
|
@@ -207,8 +212,11 @@ export const ArrayField = ({
|
|
|
207
212
|
const initial = item[childField.name]
|
|
208
213
|
|
|
209
214
|
if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
|
|
210
|
-
// Group child — render its inner fields with the group's sub-object
|
|
215
|
+
// Group child — render its inner fields with the group's sub-object.
|
|
216
|
+
// fieldAdmin slices one level per structural hop: the group's
|
|
217
|
+
// descendant map first, then each inner field's own slice.
|
|
211
218
|
const groupData = initial && typeof initial === 'object' ? initial : {}
|
|
219
|
+
const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name)
|
|
212
220
|
return (
|
|
213
221
|
<div
|
|
214
222
|
key={childField.name}
|
|
@@ -226,8 +234,10 @@ export const ArrayField = ({
|
|
|
226
234
|
defaultValue={groupData[innerField.name]}
|
|
227
235
|
basePath={`${arrayElementPath}.${childField.name}`}
|
|
228
236
|
disableSorting={true}
|
|
229
|
-
collectionPath={collectionPath}
|
|
230
237
|
contentLocale={contentLocale}
|
|
238
|
+
components={groupAdmin?.[innerField.name]?.components}
|
|
239
|
+
editor={groupAdmin?.[innerField.name]?.editor}
|
|
240
|
+
fieldAdmin={sliceFieldAdmin(groupAdmin, innerField.name)}
|
|
231
241
|
/>
|
|
232
242
|
))}
|
|
233
243
|
</div>
|
|
@@ -241,21 +251,35 @@ export const ArrayField = ({
|
|
|
241
251
|
defaultValue={initial}
|
|
242
252
|
basePath={arrayElementPath}
|
|
243
253
|
disableSorting={true}
|
|
244
|
-
collectionPath={collectionPath}
|
|
245
254
|
contentLocale={contentLocale}
|
|
255
|
+
components={fieldAdmin?.[childField.name]?.components}
|
|
256
|
+
editor={fieldAdmin?.[childField.name]?.editor}
|
|
257
|
+
fieldAdmin={sliceFieldAdmin(fieldAdmin, childField.name)}
|
|
246
258
|
/>
|
|
247
259
|
)
|
|
248
260
|
})
|
|
249
261
|
|
|
250
|
-
|
|
262
|
+
// Per-item label: the array label plus a 1-based position, so items
|
|
263
|
+
// read "Questions 1", "Questions 2" rather than all repeating the bare
|
|
264
|
+
// array label.
|
|
265
|
+
const itemLabel = `${field.label ?? field.name} ${index + 1}`
|
|
251
266
|
|
|
267
|
+
// `disableSorting` removes only the drag affordance. Structural editing
|
|
268
|
+
// (add-below / remove / collapse) is always available — StaticItem is
|
|
269
|
+
// the grip-less sibling of SortableItem (see sortable-item.tsx for why
|
|
270
|
+
// it is a separate component rather than a prop).
|
|
252
271
|
if (disableSorting) {
|
|
253
272
|
return (
|
|
254
|
-
<
|
|
273
|
+
<StaticItem
|
|
274
|
+
key={itemWrapper.id}
|
|
275
|
+
label={itemLabel}
|
|
276
|
+
onAddBelow={() => handleInsertBelow(index)}
|
|
277
|
+
onRemove={() => handleRemoveItem(index)}
|
|
278
|
+
>
|
|
255
279
|
<div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
|
|
256
280
|
{innerBody}
|
|
257
281
|
</div>
|
|
258
|
-
</
|
|
282
|
+
</StaticItem>
|
|
259
283
|
)
|
|
260
284
|
}
|
|
261
285
|
|
|
@@ -263,7 +287,7 @@ export const ArrayField = ({
|
|
|
263
287
|
<SortableItem
|
|
264
288
|
key={itemWrapper.id}
|
|
265
289
|
id={itemWrapper.id}
|
|
266
|
-
label={
|
|
290
|
+
label={itemLabel}
|
|
267
291
|
onAddBelow={() => handleInsertBelow(index)}
|
|
268
292
|
onRemove={() => handleRemoveItem(index)}
|
|
269
293
|
>
|
|
@@ -274,14 +298,43 @@ export const ArrayField = ({
|
|
|
274
298
|
)
|
|
275
299
|
}
|
|
276
300
|
|
|
301
|
+
// The add-row renders in both branches — structural editing does not
|
|
302
|
+
// depend on sortability.
|
|
303
|
+
const addRow = (
|
|
304
|
+
<div className={cx('byline-field-array-add-row', styles['add-row'])}>
|
|
305
|
+
<IconButton
|
|
306
|
+
onClick={() => {
|
|
307
|
+
void handleAddItem()
|
|
308
|
+
}}
|
|
309
|
+
aria-label={t('fields.array.addItemAriaLabel')}
|
|
310
|
+
>
|
|
311
|
+
<PlusIcon />
|
|
312
|
+
</IconButton>
|
|
313
|
+
{/* Text-styled button so the label is clickable too. Removed from
|
|
314
|
+
the tab order (tabIndex -1) — the IconButton is the single
|
|
315
|
+
keyboard/screen-reader control for this action. */}
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
tabIndex={-1}
|
|
319
|
+
onClick={() => {
|
|
320
|
+
void handleAddItem()
|
|
321
|
+
}}
|
|
322
|
+
className={cx('byline-field-array-add-label', styles['add-label'])}
|
|
323
|
+
>
|
|
324
|
+
{t('fields.array.addItem')}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
|
|
277
329
|
return (
|
|
278
330
|
<div className={`byline-field-array ${field.name}`}>
|
|
279
|
-
{
|
|
331
|
+
{field.label && (
|
|
280
332
|
<h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>
|
|
281
333
|
)}
|
|
282
334
|
{disableSorting ? (
|
|
283
335
|
<div className={cx('byline-field-array-stack', styles.stack)}>
|
|
284
336
|
{items.map((item, index) => renderItem(item, index))}
|
|
337
|
+
{addRow}
|
|
285
338
|
</div>
|
|
286
339
|
) : (
|
|
287
340
|
<DraggableSortable
|
|
@@ -290,29 +343,7 @@ export const ArrayField = ({
|
|
|
290
343
|
className={cx('byline-field-array-stack', styles.stack)}
|
|
291
344
|
>
|
|
292
345
|
{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>
|
|
346
|
+
{addRow}
|
|
316
347
|
</DraggableSortable>
|
|
317
348
|
)}
|
|
318
349
|
</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,
|
|
@@ -48,8 +49,6 @@ interface FieldRendererProps {
|
|
|
48
49
|
basePath?: string
|
|
49
50
|
disableSorting?: boolean
|
|
50
51
|
hideLabel?: boolean
|
|
51
|
-
/** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
|
|
52
|
-
collectionPath?: string
|
|
53
52
|
/**
|
|
54
53
|
* The active content locale (e.g. `'en'`, `'fr'`). When provided and
|
|
55
54
|
* `field.localized === true`, a small locale badge is shown so the editor
|
|
@@ -68,6 +67,15 @@ interface FieldRendererProps {
|
|
|
68
67
|
* Ignored when `field.type !== 'richText'`.
|
|
69
68
|
*/
|
|
70
69
|
editor?: RichTextEditorComponent
|
|
70
|
+
/**
|
|
71
|
+
* Admin overrides for this field's *descendants*, keyed by dotted,
|
|
72
|
+
* index-free schema paths relative to this field ('answer',
|
|
73
|
+
* 'filesGroup.publicationFile'). Only meaningful when `field` is a
|
|
74
|
+
* structural `group` / `array` — the widget slices the map per child
|
|
75
|
+
* (see `sliceFieldAdmin`). `components` / `editor` above stay the
|
|
76
|
+
* overrides for this field itself.
|
|
77
|
+
*/
|
|
78
|
+
fieldAdmin?: Record<string, FieldAdminConfig>
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
export const FieldRenderer = ({
|
|
@@ -76,10 +84,10 @@ export const FieldRenderer = ({
|
|
|
76
84
|
basePath,
|
|
77
85
|
disableSorting,
|
|
78
86
|
hideLabel,
|
|
79
|
-
collectionPath,
|
|
80
87
|
contentLocale,
|
|
81
88
|
components,
|
|
82
89
|
editor,
|
|
90
|
+
fieldAdmin,
|
|
83
91
|
}: FieldRendererProps) => {
|
|
84
92
|
const path = basePath ? `${basePath}.${field.name}` : field.name
|
|
85
93
|
const htmlId = path.replace(/[[\].]/g, '-')
|
|
@@ -245,7 +253,6 @@ export const FieldRenderer = ({
|
|
|
245
253
|
defaultValue={defaultValue}
|
|
246
254
|
onChange={handleChange}
|
|
247
255
|
path={path}
|
|
248
|
-
collectionPath={collectionPath}
|
|
249
256
|
/>
|
|
250
257
|
)
|
|
251
258
|
case 'image':
|
|
@@ -255,7 +262,6 @@ export const FieldRenderer = ({
|
|
|
255
262
|
defaultValue={defaultValue}
|
|
256
263
|
onChange={handleChange}
|
|
257
264
|
path={path}
|
|
258
|
-
collectionPath={collectionPath}
|
|
259
265
|
/>
|
|
260
266
|
)
|
|
261
267
|
case 'relation':
|
|
@@ -289,8 +295,9 @@ export const FieldRenderer = ({
|
|
|
289
295
|
}
|
|
290
296
|
defaultValue={defaultValue}
|
|
291
297
|
path={path}
|
|
292
|
-
|
|
298
|
+
disableSorting={disableSorting}
|
|
293
299
|
contentLocale={contentLocale}
|
|
300
|
+
fieldAdmin={fieldAdmin}
|
|
294
301
|
/>
|
|
295
302
|
)
|
|
296
303
|
case 'blocks':
|
|
@@ -311,8 +318,8 @@ export const FieldRenderer = ({
|
|
|
311
318
|
defaultValue={defaultValue}
|
|
312
319
|
path={path}
|
|
313
320
|
disableSorting={disableSorting}
|
|
314
|
-
collectionPath={collectionPath}
|
|
315
321
|
contentLocale={contentLocale}
|
|
322
|
+
fieldAdmin={fieldAdmin}
|
|
316
323
|
/>
|
|
317
324
|
)
|
|
318
325
|
default:
|
|
@@ -57,8 +57,6 @@ function triggerDownload(url: string, filename?: string) {
|
|
|
57
57
|
|
|
58
58
|
interface FileFieldProps {
|
|
59
59
|
field: FieldType
|
|
60
|
-
/** Collection path required to call the /upload endpoint. */
|
|
61
|
-
collectionPath?: string
|
|
62
60
|
value?: StoredFileValue | null
|
|
63
61
|
defaultValue?: StoredFileValue | null
|
|
64
62
|
onChange?: (value: StoredFileValue | null) => void
|
|
@@ -67,7 +65,6 @@ interface FileFieldProps {
|
|
|
67
65
|
|
|
68
66
|
export const FileField = ({
|
|
69
67
|
field,
|
|
70
|
-
collectionPath,
|
|
71
68
|
value,
|
|
72
69
|
defaultValue,
|
|
73
70
|
onChange: _onChange,
|
|
@@ -79,7 +76,10 @@ export const FileField = ({
|
|
|
79
76
|
const isDirty = useIsDirty(fieldPath)
|
|
80
77
|
const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
|
|
81
78
|
const isUploading = useIsFieldUploading(fieldPath)
|
|
82
|
-
|
|
79
|
+
// `collectionPath` comes from form context rather than a prop: it is
|
|
80
|
+
// constant for the form, and prop-drilling it meant any container that
|
|
81
|
+
// forgot to forward it silently rendered this widget read-only.
|
|
82
|
+
const { removePendingUpload, documentId, collectionPath } = useFormContext()
|
|
83
83
|
|
|
84
84
|
const handleChange = useFieldChangeHandler(field, fieldPath)
|
|
85
85
|
|