@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.
Files changed (36) hide show
  1. package/dist/fields/array/array-field.d.ts +12 -9
  2. package/dist/fields/array/array-field.js +46 -34
  3. package/dist/fields/blocks/blocks-field.js +1 -0
  4. package/dist/fields/draggable-context-menu.js +2 -1
  5. package/dist/fields/field-admin.d.ts +15 -0
  6. package/dist/fields/field-admin.js +11 -0
  7. package/dist/fields/field-admin.test.node.d.ts +8 -0
  8. package/dist/fields/field-renderer.d.ts +11 -4
  9. package/dist/fields/field-renderer.js +8 -9
  10. package/dist/fields/file/file-field.d.ts +1 -3
  11. package/dist/fields/file/file-field.js +2 -2
  12. package/dist/fields/group/group-field.d.ts +16 -9
  13. package/dist/fields/group/group-field.js +5 -4
  14. package/dist/fields/image/image-field.d.ts +1 -3
  15. package/dist/fields/image/image-field.js +2 -2
  16. package/dist/fields/sortable-item.d.ts +6 -0
  17. package/dist/fields/sortable-item.js +44 -25
  18. package/dist/forms/form-context.d.ts +20 -1
  19. package/dist/forms/form-context.js +3 -2
  20. package/dist/forms/form-renderer.js +4 -2
  21. package/dist/forms/upload-executor.js +50 -29
  22. package/package.json +10 -10
  23. package/src/fields/array/array-field.tsx +74 -43
  24. package/src/fields/blocks/blocks-field.tsx +5 -0
  25. package/src/fields/draggable-context-menu.tsx +9 -1
  26. package/src/fields/field-admin.test.node.ts +49 -0
  27. package/src/fields/field-admin.ts +39 -0
  28. package/src/fields/field-renderer.tsx +14 -7
  29. package/src/fields/file/file-field.tsx +4 -4
  30. package/src/fields/group/group-field.tsx +19 -11
  31. package/src/fields/image/image-field.tsx +4 -4
  32. package/src/fields/sortable-item.tsx +115 -34
  33. package/src/forms/form-context.tsx +30 -1
  34. package/src/forms/form-renderer.tsx +3 -1
  35. package/src/forms/upload-executor.test.node.ts +187 -0
  36. 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 contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath)?.context : void 0;
40
- if (contextPaths && contextPaths.length > 0 && executionContext?.getFormValues) {
41
- const formValues = executionContext.getFormValues();
42
- 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);
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 segments = fieldPath.split('.').map((s)=>s.replace(/\[\d+\]$/, ''));
94
- let currentFields = fields;
95
- for(let i = 0; i < segments.length; i++){
96
- const name = segments[i];
97
- const isLeaf = i === segments.length - 1;
98
- const field = currentFields.find((f)=>f.name === name);
99
- if (!field) break;
100
- if (isLeaf) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
101
- if ('group' === field.type || 'array' === field.type) {
102
- currentFields = field.fields;
103
- continue;
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
- if ('blocks' === field.type) {
106
- const remaining = segments[i + 1];
107
- const block = field.blocks.find((b)=>b.fields.some((f)=>f.name === remaining));
108
- if (!block) return;
109
- currentFields = block.fields;
110
- continue;
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
- break;
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.2.0",
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.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/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.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 = ({
@@ -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
- const label = field.label ?? field.name
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
- <div key={itemWrapper.id} className={cx('byline-field-array-card', styles.card)}>
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
- </div>
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={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
- {!disableSorting && field.label && (
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
- <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>
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 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,
@@ -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
- collectionPath={collectionPath}
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
- const { removePendingUpload, documentId } = useFormContext()
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