@byline/admin 4.3.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.
@@ -6,18 +6,11 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { ArrayField as ArrayFieldType, FieldAdminConfig } from '@byline/core';
9
- export declare const ArrayField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: {
9
+ export declare const ArrayField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: {
10
10
  field: ArrayFieldType;
11
11
  defaultValue: any;
12
12
  path: string;
13
13
  disableSorting?: boolean;
14
- /**
15
- * Collection path forwarded to upload-capable fields (`file` / `image`)
16
- * nested inside an array item, which need it to reach the `/upload`
17
- * endpoint. Without it those fields fall back to their empty placeholder
18
- * and never render an upload widget.
19
- */
20
- collectionPath?: string;
21
14
  /**
22
15
  * Active content locale, forwarded to each array item's fields so
23
16
  * localized widgets nested inside an array (e.g. a `localized` richText)
@@ -9,7 +9,7 @@ import { FieldRenderer } from "../field-renderer.js";
9
9
  import { SortableItem, StaticItem } from "../sortable-item.js";
10
10
  import { useFormContext } from "../../forms/form-context.js";
11
11
  import array_field_module from "./array-field.module.js";
12
- const ArrayField = ({ field, defaultValue, path, disableSorting = false, collectionPath, contentLocale, fieldAdmin })=>{
12
+ const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin })=>{
13
13
  const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext();
14
14
  const { t } = useTranslation('byline-admin');
15
15
  const [items, setItems] = useState([]);
@@ -127,7 +127,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
127
127
  defaultValue: groupData[innerField.name],
128
128
  basePath: `${arrayElementPath}.${childField.name}`,
129
129
  disableSorting: true,
130
- collectionPath: collectionPath,
131
130
  contentLocale: contentLocale,
132
131
  components: groupAdmin?.[innerField.name]?.components,
133
132
  editor: groupAdmin?.[innerField.name]?.editor,
@@ -141,7 +140,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
141
140
  defaultValue: initial,
142
141
  basePath: arrayElementPath,
143
142
  disableSorting: true,
144
- collectionPath: collectionPath,
145
143
  contentLocale: contentLocale,
146
144
  components: fieldAdmin?.[childField.name]?.components,
147
145
  editor: fieldAdmin?.[childField.name]?.editor,
@@ -12,8 +12,6 @@ interface FieldRendererProps {
12
12
  basePath?: string;
13
13
  disableSorting?: boolean;
14
14
  hideLabel?: boolean;
15
- /** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
16
- collectionPath?: string;
17
15
  /**
18
16
  * The active content locale (e.g. `'en'`, `'fr'`). When provided and
19
17
  * `field.localized === true`, a small locale badge is shown so the editor
@@ -42,5 +40,5 @@ interface FieldRendererProps {
42
40
  */
43
41
  fieldAdmin?: Record<string, FieldAdminConfig>;
44
42
  }
45
- export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
43
+ export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
46
44
  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, fieldAdmin })=>{
23
+ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, 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);
@@ -161,8 +161,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
161
161
  } : field,
162
162
  defaultValue: defaultValue,
163
163
  onChange: handleChange,
164
- path: path,
165
- collectionPath: collectionPath
164
+ path: path
166
165
  });
167
166
  case 'image':
168
167
  return /*#__PURE__*/ jsx(ImageField, {
@@ -172,8 +171,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
172
171
  } : field,
173
172
  defaultValue: defaultValue,
174
173
  onChange: handleChange,
175
- path: path,
176
- collectionPath: collectionPath
174
+ path: path
177
175
  });
178
176
  case 'relation':
179
177
  if (field.hasMany) return /*#__PURE__*/ jsx(RelationManyField, {
@@ -204,7 +202,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
204
202
  defaultValue: defaultValue,
205
203
  path: path,
206
204
  disableSorting: disableSorting,
207
- collectionPath: collectionPath,
208
205
  contentLocale: contentLocale,
209
206
  fieldAdmin: fieldAdmin
210
207
  });
@@ -223,7 +220,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
223
220
  defaultValue: defaultValue,
224
221
  path: path,
225
222
  disableSorting: disableSorting,
226
- collectionPath: collectionPath,
227
223
  contentLocale: contentLocale,
228
224
  fieldAdmin: fieldAdmin
229
225
  });
@@ -8,12 +8,10 @@
8
8
  import { type FileField as FieldType, type StoredFileValue } from '@byline/core';
9
9
  interface FileFieldProps {
10
10
  field: FieldType;
11
- /** Collection path required to call the /upload endpoint. */
12
- collectionPath?: string;
13
11
  value?: StoredFileValue | null;
14
12
  defaultValue?: StoredFileValue | null;
15
13
  onChange?: (value: StoredFileValue | null) => void;
16
14
  path?: string;
17
15
  }
18
- export declare const FileField: ({ field, collectionPath, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => import("react").JSX.Element;
16
+ export declare const FileField: ({ field, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => import("react").JSX.Element;
19
17
  export {};
@@ -18,14 +18,14 @@ function triggerDownload(url, filename) {
18
18
  a.click();
19
19
  document.body.removeChild(a);
20
20
  }
21
- const FileField = ({ field, collectionPath, value, defaultValue, onChange: _onChange, path })=>{
21
+ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
22
22
  const fieldPath = path ?? field.name;
23
23
  const { t } = useTranslation('byline-admin');
24
24
  const fieldError = useFieldError(fieldPath);
25
25
  const isDirty = useIsDirty(fieldPath);
26
26
  const fieldValue = useFieldValue(fieldPath);
27
27
  const isUploading = useIsFieldUploading(fieldPath);
28
- const { removePendingUpload, documentId } = useFormContext();
28
+ const { removePendingUpload, documentId, collectionPath } = useFormContext();
29
29
  const handleChange = useFieldChangeHandler(field, fieldPath);
30
30
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
31
31
  const isPending = isPendingStoredFileValue(incomingValue);
@@ -20,12 +20,6 @@ interface GroupFieldProps {
20
20
  * listeners.
21
21
  */
22
22
  disableSorting?: boolean;
23
- /**
24
- * Collection path forwarded to upload-capable child fields (`file` / `image`),
25
- * which need it to reach the `/upload` endpoint. Without it those fields fall
26
- * back to their empty placeholder and never render an upload widget.
27
- */
28
- collectionPath?: string;
29
23
  /**
30
24
  * Active content locale, forwarded to child fields so localized widgets
31
25
  * nested inside the group (e.g. a `localized` richText) can render their
@@ -44,5 +38,5 @@ interface GroupFieldProps {
44
38
  */
45
39
  fieldAdmin?: Record<string, FieldAdminConfig>;
46
40
  }
47
- export declare const GroupField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
41
+ export declare const GroupField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
48
42
  export {};
@@ -7,7 +7,7 @@ import { placeholderForField } from "../field-helpers.js";
7
7
  import { FieldRenderer } from "../field-renderer.js";
8
8
  import { useFieldError } from "../../forms/form-context.js";
9
9
  import group_field_module from "./group-field.module.js";
10
- const GroupField = ({ field, defaultValue, path, disableSorting = true, collectionPath, contentLocale, fieldAdmin })=>{
10
+ const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin })=>{
11
11
  const fieldError = useFieldError(field.name);
12
12
  const groupData = useMemo(()=>{
13
13
  if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
@@ -48,7 +48,6 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, collecti
48
48
  defaultValue: groupData[innerField.name],
49
49
  basePath: path,
50
50
  disableSorting: disableSorting,
51
- collectionPath: collectionPath,
52
51
  contentLocale: contentLocale,
53
52
  components: fieldAdmin?.[innerField.name]?.components,
54
53
  editor: fieldAdmin?.[innerField.name]?.editor,
@@ -8,12 +8,10 @@
8
8
  import { type ImageField as FieldType, type StoredFileValue } from '@byline/core';
9
9
  interface ImageFieldProps {
10
10
  field: FieldType;
11
- /** Collection path required to call the /upload endpoint. */
12
- collectionPath?: string;
13
11
  value?: StoredFileValue | null;
14
12
  defaultValue?: StoredFileValue | null;
15
13
  onChange?: (value: StoredFileValue | null) => void;
16
14
  path?: string;
17
15
  }
18
- export declare const ImageField: ({ field, collectionPath, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => import("react").JSX.Element;
16
+ export declare const ImageField: ({ field, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => import("react").JSX.Element;
19
17
  export {};
@@ -8,13 +8,13 @@ import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUpl
8
8
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
9
9
  import image_field_module from "./image-field.module.js";
10
10
  import { ImageUploadField } from "./image-upload-field.js";
11
- const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onChange, path })=>{
11
+ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
12
12
  const fieldPath = path ?? field.name;
13
13
  const fieldError = useFieldError(fieldPath);
14
14
  const isDirty = useIsDirty(fieldPath);
15
15
  const fieldValue = useFieldValue(fieldPath);
16
16
  const isUploading = useIsFieldUploading(fieldPath);
17
- const { removePendingUpload, documentId } = useFormContext();
17
+ const { removePendingUpload, documentId, collectionPath } = useFormContext();
18
18
  const { t } = useTranslation('byline-admin');
19
19
  const handleChange = useFieldChangeHandler(field, fieldPath);
20
20
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
@@ -55,6 +55,19 @@ interface FormContextType {
55
55
  * `@byline/core`).
56
56
  */
57
57
  documentId: string | null;
58
+ /**
59
+ * Path of the collection this form edits, `null` when the form is rendered
60
+ * without one. Upload widgets need it to address the upload endpoint.
61
+ *
62
+ * It lives here rather than being passed down because it is constant for
63
+ * the whole form: threading it as a prop meant every nesting-capable
64
+ * container (`array`, `group`, `blocks`) had to remember to forward it,
65
+ * and a container that forgot silently rendered upload fields read-only —
66
+ * no error, just a missing drop zone. That happened twice, in `array` /
67
+ * `group` and then in `blocks`. A value read from context cannot be
68
+ * dropped by a container that never carries it.
69
+ */
70
+ collectionPath: string | null;
58
71
  setFieldValue: (name: string, value: any) => void;
59
72
  setFieldStore: (name: string, value: any) => void;
60
73
  getFieldValue: (name: string) => any;
@@ -97,7 +110,7 @@ interface FormContextType {
97
110
  subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
98
111
  }
99
112
  export declare const useFormContext: () => FormContextType;
100
- export declare const FormProvider: ({ children, initialData, documentId, }: {
113
+ export declare const FormProvider: ({ children, initialData, documentId, collectionPath, }: {
101
114
  children: React.ReactNode;
102
115
  initialData?: Record<string, any>;
103
116
  /**
@@ -105,6 +118,12 @@ export declare const FormProvider: ({ children, initialData, documentId, }: {
105
118
  * the context for upload widgets honouring `upload.requireSavedDocument`.
106
119
  */
107
120
  documentId?: string | null;
121
+ /**
122
+ * Path of the collection being edited. Exposed on the context so upload
123
+ * widgets can reach the upload endpoint from any nesting depth without
124
+ * every container forwarding it — see `FormContextType.collectionPath`.
125
+ */
126
+ collectionPath?: string | null;
108
127
  }) => React.JSX.Element;
109
128
  /**
110
129
  * Subscribe to the system `path` slot edited by the path widget.
@@ -22,7 +22,7 @@ const useFormContext = ()=>{
22
22
  if (null == context) throw new Error('useFormContext must be used within a FormProvider');
23
23
  return context;
24
24
  };
25
- const FormProvider = ({ children, initialData = {}, documentId = null })=>{
25
+ const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null })=>{
26
26
  const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
27
27
  const initialValues = useRef(initialData?.fields ?? initialData);
28
28
  const errorsRef = useRef([]);
@@ -377,6 +377,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
377
377
  return /*#__PURE__*/ jsx(FormContext.Provider, {
378
378
  value: {
379
379
  documentId,
380
+ collectionPath,
380
381
  setFieldValue,
381
382
  setFieldStore,
382
383
  getFieldValue,
@@ -183,7 +183,6 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
183
183
  return /*#__PURE__*/ jsx(FieldRenderer, {
184
184
  field: field,
185
185
  defaultValue: initialData?.fields?.[field.name],
186
- collectionPath: collectionPath,
187
186
  contentLocale: contentLocale,
188
187
  components: adminConfig?.fields?.[field.name]?.components,
189
188
  editor: adminConfig?.fields?.[field.name]?.editor,
@@ -405,6 +404,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
405
404
  return /*#__PURE__*/ jsx(FormProvider, {
406
405
  initialData: initialData,
407
406
  documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
407
+ collectionPath: collectionPath ?? null,
408
408
  children: /*#__PURE__*/ jsx(FormContent, {
409
409
  mode: mode,
410
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.3.0",
5
+ "version": "4.4.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -168,10 +168,10 @@
168
168
  "react-diff-viewer-continued": "^4.4.0",
169
169
  "uuid": "^14.0.1",
170
170
  "zod": "^4.4.3",
171
- "@byline/core": "4.3.0",
172
- "@byline/auth": "4.3.0",
173
- "@byline/i18n": "4.3.0",
174
- "@byline/ui": "4.3.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",
@@ -31,7 +31,6 @@ export const ArrayField = ({
31
31
  defaultValue,
32
32
  path,
33
33
  disableSorting = false,
34
- collectionPath,
35
34
  contentLocale,
36
35
  fieldAdmin,
37
36
  }: {
@@ -39,13 +38,6 @@ export const ArrayField = ({
39
38
  defaultValue: any
40
39
  path: string
41
40
  disableSorting?: boolean
42
- /**
43
- * Collection path forwarded to upload-capable fields (`file` / `image`)
44
- * nested inside an array item, which need it to reach the `/upload`
45
- * endpoint. Without it those fields fall back to their empty placeholder
46
- * and never render an upload widget.
47
- */
48
- collectionPath?: string
49
41
  /**
50
42
  * Active content locale, forwarded to each array item's fields so
51
43
  * localized widgets nested inside an array (e.g. a `localized` richText)
@@ -242,7 +234,6 @@ export const ArrayField = ({
242
234
  defaultValue={groupData[innerField.name]}
243
235
  basePath={`${arrayElementPath}.${childField.name}`}
244
236
  disableSorting={true}
245
- collectionPath={collectionPath}
246
237
  contentLocale={contentLocale}
247
238
  components={groupAdmin?.[innerField.name]?.components}
248
239
  editor={groupAdmin?.[innerField.name]?.editor}
@@ -260,7 +251,6 @@ export const ArrayField = ({
260
251
  defaultValue={initial}
261
252
  basePath={arrayElementPath}
262
253
  disableSorting={true}
263
- collectionPath={collectionPath}
264
254
  contentLocale={contentLocale}
265
255
  components={fieldAdmin?.[childField.name]?.components}
266
256
  editor={fieldAdmin?.[childField.name]?.editor}
@@ -49,8 +49,6 @@ interface FieldRendererProps {
49
49
  basePath?: string
50
50
  disableSorting?: boolean
51
51
  hideLabel?: boolean
52
- /** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
53
- collectionPath?: string
54
52
  /**
55
53
  * The active content locale (e.g. `'en'`, `'fr'`). When provided and
56
54
  * `field.localized === true`, a small locale badge is shown so the editor
@@ -86,7 +84,6 @@ export const FieldRenderer = ({
86
84
  basePath,
87
85
  disableSorting,
88
86
  hideLabel,
89
- collectionPath,
90
87
  contentLocale,
91
88
  components,
92
89
  editor,
@@ -256,7 +253,6 @@ export const FieldRenderer = ({
256
253
  defaultValue={defaultValue}
257
254
  onChange={handleChange}
258
255
  path={path}
259
- collectionPath={collectionPath}
260
256
  />
261
257
  )
262
258
  case 'image':
@@ -266,7 +262,6 @@ export const FieldRenderer = ({
266
262
  defaultValue={defaultValue}
267
263
  onChange={handleChange}
268
264
  path={path}
269
- collectionPath={collectionPath}
270
265
  />
271
266
  )
272
267
  case 'relation':
@@ -301,7 +296,6 @@ export const FieldRenderer = ({
301
296
  defaultValue={defaultValue}
302
297
  path={path}
303
298
  disableSorting={disableSorting}
304
- collectionPath={collectionPath}
305
299
  contentLocale={contentLocale}
306
300
  fieldAdmin={fieldAdmin}
307
301
  />
@@ -324,7 +318,6 @@ export const FieldRenderer = ({
324
318
  defaultValue={defaultValue}
325
319
  path={path}
326
320
  disableSorting={disableSorting}
327
- collectionPath={collectionPath}
328
321
  contentLocale={contentLocale}
329
322
  fieldAdmin={fieldAdmin}
330
323
  />
@@ -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
 
@@ -43,12 +43,6 @@ interface GroupFieldProps {
43
43
  * listeners.
44
44
  */
45
45
  disableSorting?: boolean
46
- /**
47
- * Collection path forwarded to upload-capable child fields (`file` / `image`),
48
- * which need it to reach the `/upload` endpoint. Without it those fields fall
49
- * back to their empty placeholder and never render an upload widget.
50
- */
51
- collectionPath?: string
52
46
  /**
53
47
  * Active content locale, forwarded to child fields so localized widgets
54
48
  * nested inside the group (e.g. a `localized` richText) can render their
@@ -73,7 +67,6 @@ export const GroupField = ({
73
67
  defaultValue,
74
68
  path,
75
69
  disableSorting = true,
76
- collectionPath,
77
70
  contentLocale,
78
71
  fieldAdmin,
79
72
  }: GroupFieldProps) => {
@@ -116,7 +109,6 @@ export const GroupField = ({
116
109
  defaultValue={groupData[innerField.name]}
117
110
  basePath={path}
118
111
  disableSorting={disableSorting}
119
- collectionPath={collectionPath}
120
112
  contentLocale={contentLocale}
121
113
  components={fieldAdmin?.[innerField.name]?.components}
122
114
  editor={fieldAdmin?.[innerField.name]?.editor}
@@ -38,8 +38,6 @@ import { ImageUploadField } from './image-upload-field'
38
38
 
39
39
  interface ImageFieldProps {
40
40
  field: FieldType
41
- /** Collection path required to call the /upload endpoint. */
42
- collectionPath?: string
43
41
  // Stored value is currently a plain object with file/image metadata
44
42
  // coming from the seed data / storage layer.
45
43
  value?: StoredFileValue | null
@@ -50,7 +48,6 @@ interface ImageFieldProps {
50
48
 
51
49
  export const ImageField = ({
52
50
  field,
53
- collectionPath,
54
51
  value,
55
52
  defaultValue,
56
53
  onChange: _onChange,
@@ -61,7 +58,10 @@ export const ImageField = ({
61
58
  const isDirty = useIsDirty(fieldPath)
62
59
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
63
60
  const isUploading = useIsFieldUploading(fieldPath)
64
- const { removePendingUpload, documentId } = useFormContext()
61
+ // `collectionPath` comes from form context rather than a prop: it is
62
+ // constant for the form, and prop-drilling it meant any container that
63
+ // forgot to forward it silently rendered this widget read-only.
64
+ const { removePendingUpload, documentId, collectionPath } = useFormContext()
65
65
  const { t } = useTranslation('byline-admin')
66
66
 
67
67
  // Re-use the standard field change handler so patches are emitted correctly.
@@ -90,6 +90,19 @@ interface FormContextType {
90
90
  * `@byline/core`).
91
91
  */
92
92
  documentId: string | null
93
+ /**
94
+ * Path of the collection this form edits, `null` when the form is rendered
95
+ * without one. Upload widgets need it to address the upload endpoint.
96
+ *
97
+ * It lives here rather than being passed down because it is constant for
98
+ * the whole form: threading it as a prop meant every nesting-capable
99
+ * container (`array`, `group`, `blocks`) had to remember to forward it,
100
+ * and a container that forgot silently rendered upload fields read-only —
101
+ * no error, just a missing drop zone. That happened twice, in `array` /
102
+ * `group` and then in `blocks`. A value read from context cannot be
103
+ * dropped by a container that never carries it.
104
+ */
105
+ collectionPath: string | null
93
106
  setFieldValue: (name: string, value: any) => void
94
107
  setFieldStore: (name: string, value: any) => void
95
108
  getFieldValue: (name: string) => any
@@ -158,6 +171,7 @@ export const FormProvider = ({
158
171
  children,
159
172
  initialData = {},
160
173
  documentId = null,
174
+ collectionPath = null,
161
175
  }: {
162
176
  children: React.ReactNode
163
177
  initialData?: Record<string, any>
@@ -166,6 +180,12 @@ export const FormProvider = ({
166
180
  * the context for upload widgets honouring `upload.requireSavedDocument`.
167
181
  */
168
182
  documentId?: string | null
183
+ /**
184
+ * Path of the collection being edited. Exposed on the context so upload
185
+ * widgets can reach the upload endpoint from any nesting depth without
186
+ * every container forwarding it — see `FormContextType.collectionPath`.
187
+ */
188
+ collectionPath?: string | null
169
189
  }) => {
170
190
  const fieldValues = useRef<Record<string, any>>(
171
191
  JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
@@ -662,6 +682,7 @@ export const FormProvider = ({
662
682
  <FormContext.Provider
663
683
  value={{
664
684
  documentId,
685
+ collectionPath,
665
686
  setFieldValue,
666
687
  setFieldStore,
667
688
  getFieldValue,
@@ -485,7 +485,6 @@ const FormContent = ({
485
485
  key={field.name}
486
486
  field={field}
487
487
  defaultValue={initialData?.fields?.[field.name]}
488
- collectionPath={collectionPath}
489
488
  contentLocale={contentLocale}
490
489
  components={adminConfig?.fields?.[field.name]?.components}
491
490
  editor={adminConfig?.fields?.[field.name]?.editor}
@@ -778,6 +777,7 @@ export const FormRenderer = ({
778
777
  key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
779
778
  initialData={initialData}
780
779
  documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
780
+ collectionPath={collectionPath ?? null}
781
781
  >
782
782
  <FormContent
783
783
  mode={mode}
@@ -56,6 +56,76 @@ const publicationsFields: Field[] = [
56
56
  },
57
57
  ]
58
58
 
59
+ /**
60
+ * Two block types that both declare an array named `gallery`, each holding a
61
+ * different upload-capable leaf. Leaf names stay unique across the
62
+ * collection, so this passes the boot-time uniqueness constraint — the shape
63
+ * is legal, and it is the shape that made block resolution ambiguous.
64
+ */
65
+ const blocksFields: Field[] = [
66
+ { name: 'title', label: 'Title', type: 'text' },
67
+ {
68
+ name: 'content',
69
+ label: 'Content',
70
+ type: 'blocks',
71
+ blocks: [
72
+ {
73
+ blockType: 'photoBlock',
74
+ fields: [
75
+ {
76
+ name: 'gallery',
77
+ label: 'Gallery',
78
+ type: 'array',
79
+ fields: [
80
+ {
81
+ name: 'heroImage',
82
+ label: 'Hero',
83
+ type: 'image',
84
+ upload: { context: ['/title'] },
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ },
90
+ {
91
+ blockType: 'videoBlock',
92
+ fields: [
93
+ {
94
+ name: 'gallery',
95
+ label: 'Gallery',
96
+ type: 'array',
97
+ fields: [
98
+ {
99
+ name: 'poster',
100
+ label: 'Poster',
101
+ type: 'image',
102
+ upload: { context: ['/title'] },
103
+ },
104
+ ],
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ },
110
+ ]
111
+
112
+ /** Form state for `blocksFields`: a photoBlock then a videoBlock. */
113
+ const blocksFormValues = () => ({
114
+ title: 'Hello',
115
+ content: [
116
+ { _type: 'photoBlock', _id: 'blk-photo', gallery: [{}] },
117
+ { _type: 'videoBlock', _id: 'blk-video', gallery: [{}] },
118
+ ],
119
+ })
120
+
121
+ function imageUpload(name = 'p.png'): PendingUpload {
122
+ return {
123
+ file: new File(['x'], name, { type: 'image/png' }),
124
+ previewUrl: 'blob:mock',
125
+ collectionPath: 'pages',
126
+ }
127
+ }
128
+
59
129
  function pendingUpload(name = 'test.pdf'): PendingUpload {
60
130
  return {
61
131
  file: new File(['%PDF'], name, { type: 'application/pdf' }),
@@ -174,3 +244,120 @@ describe('executeUploads — context transmission', () => {
174
244
  expect(body.get('documentId')).toBeNull()
175
245
  })
176
246
  })
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Block resolution
250
+ //
251
+ // A form field path is an INSTANCE path — `content[1].gallery[0].poster` — and
252
+ // carries no block type, because a block item holds its own `_type`. Resolving
253
+ // an upload field inside a block therefore means reading that item.
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('executeUploads — upload fields inside blocks', () => {
257
+ it('resolves upload.context for a field in the first block', async () => {
258
+ const { fn, bodies } = captureUploadField()
259
+ const uploads = new Map([['content[0].gallery[0].heroImage', imageUpload('h.png')]])
260
+
261
+ await executeUploads(uploads, fn, {
262
+ fields: blocksFields,
263
+ getFormValues: blocksFormValues,
264
+ })
265
+
266
+ expect(bodies[0]?.get('title')).toBe('Hello')
267
+ })
268
+
269
+ it('resolves upload.context for a field in a later block', async () => {
270
+ // Regression: block resolution used to match the first block declaring a
271
+ // field named by the next path segment. Both blocks declare `gallery`, so
272
+ // `photoBlock` won, `poster` was not found in it, and the declared context
273
+ // was dropped silently — no error, just a missing value the server-side
274
+ // beforeStore / afterStore hooks were relying on.
275
+ const { fn, bodies } = captureUploadField()
276
+ const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
277
+
278
+ await executeUploads(uploads, fn, {
279
+ fields: blocksFields,
280
+ getFormValues: blocksFormValues,
281
+ })
282
+
283
+ expect(bodies[0]?.get('title')).toBe('Hello')
284
+ })
285
+
286
+ it('addresses a block item by stable id as well as position', async () => {
287
+ const { fn, bodies } = captureUploadField()
288
+ const uploads = new Map([['content[id=blk-video].gallery[0].poster', imageUpload()]])
289
+
290
+ await executeUploads(uploads, fn, {
291
+ fields: blocksFields,
292
+ getFormValues: blocksFormValues,
293
+ })
294
+
295
+ expect(bodies[0]?.get('title')).toBe('Hello')
296
+ })
297
+
298
+ it('still resolves when the addressed block item is missing from form state', async () => {
299
+ // Form state can lag a pending upload. The item is what supplies `_type`,
300
+ // so its absence drops us to the unique-match fallback — but the *field*
301
+ // is a declaration, and `poster` is declared in exactly one block, so the
302
+ // answer is unchanged. Staleness costs nothing here; genuine ambiguity
303
+ // would still return nothing (see the ambiguous case below).
304
+ const { fn, bodies } = captureUploadField()
305
+ const uploads = new Map([['content[7].gallery[0].poster', imageUpload()]])
306
+
307
+ await executeUploads(uploads, fn, {
308
+ fields: blocksFields,
309
+ getFormValues: blocksFormValues,
310
+ })
311
+
312
+ expect(bodies[0]?.get('title')).toBe('Hello')
313
+ expect(bodies[0]?.get('fieldPath')).toBe('content[7].gallery[0].poster')
314
+ })
315
+
316
+ it('falls back to a unique match when form values cannot disambiguate', async () => {
317
+ // Without data the block type is unknowable from the path. `poster` is
318
+ // declared in exactly one block, so the answer is still unambiguous.
319
+ const { fn, bodies } = captureUploadField()
320
+ const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
321
+
322
+ await executeUploads(uploads, fn, {
323
+ fields: blocksFields,
324
+ getFormValues: () => ({ title: 'Hello' }),
325
+ })
326
+
327
+ expect(bodies[0]?.get('title')).toBe('Hello')
328
+ })
329
+
330
+ it('sends no context when the path genuinely identifies no single declaration', async () => {
331
+ // Same leaf name in both blocks and no data to choose between them. A
332
+ // guess would be wrong half the time, so nothing is sent.
333
+ const ambiguous: Field[] = [
334
+ { name: 'title', label: 'Title', type: 'text' },
335
+ {
336
+ name: 'content',
337
+ label: 'Content',
338
+ type: 'blocks',
339
+ blocks: [
340
+ {
341
+ blockType: 'aBlock',
342
+ fields: [
343
+ { name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
344
+ ],
345
+ },
346
+ {
347
+ blockType: 'bBlock',
348
+ fields: [
349
+ { name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
350
+ ],
351
+ },
352
+ ],
353
+ },
354
+ ]
355
+
356
+ const { fn, bodies } = captureUploadField()
357
+ const uploads = new Map([['content[1].shared', imageUpload()]])
358
+
359
+ await executeUploads(uploads, fn, { fields: ambiguous, getFormValues: () => ({ title: 'x' }) })
360
+
361
+ expect(bodies[0]?.get('title')).toBeNull()
362
+ })
363
+ })
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Field, StoredFileValue, UploadConfig } from '@byline/core'
18
+ import { type PathSegment, parseInstancePath } from '@byline/core'
18
19
 
19
20
  import { get as getNestedValue } from './nested-path'
20
21
  import type { UploadFieldFn } from '../fields/field-services-types'
@@ -149,13 +150,16 @@ function buildUploadFormData(
149
150
  formData.append('documentId', executionContext.documentId)
150
151
  }
151
152
 
153
+ // Resolved once per upload, before locating the field: a `blocks` hop in
154
+ // the path can only be resolved by reading the addressed item's `_type`.
155
+ const formValues = executionContext?.getFormValues?.()
156
+
152
157
  const contextPaths =
153
158
  executionContext?.fields != null
154
- ? findUploadFieldByPath(executionContext.fields, fieldPath)?.context
159
+ ? findUploadFieldByPath(executionContext.fields, fieldPath, formValues)?.context
155
160
  : undefined
156
161
 
157
- if (contextPaths && contextPaths.length > 0 && executionContext?.getFormValues) {
158
- const formValues = executionContext.getFormValues()
162
+ if (contextPaths && contextPaths.length > 0 && formValues) {
159
163
  for (const contextPath of contextPaths) {
160
164
  const resolvedPath = resolveContextPath(fieldPath, contextPath)
161
165
  if (resolvedPath === undefined) continue
@@ -263,43 +267,100 @@ function isRelationEnvelope(value: unknown): value is { targetDocumentId: string
263
267
 
264
268
  /**
265
269
  * Locate the upload-capable `image | file` schema field addressed by a form
266
- * field path, walking `group` / `array` / `blocks` structures. Array indices
267
- * in the path (`files[2]`) map onto the schema's repeating field (`files`);
268
- * blocks are matched by trying each block's field set (block-type info is
269
- * not encoded in the path, so the first block containing the remaining path
270
- * wins upload leaf names must be unique among a collection's
271
- * upload-capable fields anyway, per the server-side resolver's contract).
270
+ * field path, walking `group` / `array` / `blocks` structures.
271
+ *
272
+ * The path is an *instance* path (`content[1].gallery[0].poster`), so it
273
+ * carries no block type a block item's type lives on the item itself, as
274
+ * `_type`. Passing `formValues` therefore lets a `blocks` hop be resolved
275
+ * exactly: read the addressed item, take its `_type`, descend into that
276
+ * block. Without form values the block type is genuinely unknowable from the
277
+ * path, so every block is tried and the result is accepted only when exactly
278
+ * one resolves — ambiguity returns `undefined` rather than a guess.
272
279
  */
273
280
  function findUploadFieldByPath(
274
281
  fields: readonly Field[],
275
- fieldPath: string
282
+ fieldPath: string,
283
+ formValues?: Record<string, any>
276
284
  ): UploadConfig | undefined {
277
- const segments = fieldPath.split('.').map((s) => s.replace(/\[\d+\]$/, ''))
285
+ const parsed = parseInstancePath(fieldPath)
286
+ if (!parsed.ok) return undefined
287
+ return resolveUploadConfig(fields, parsed.segments, formValues)
288
+ }
278
289
 
279
- let currentFields: readonly Field[] = fields
280
- for (let i = 0; i < segments.length; i++) {
281
- const name = segments[i]
282
- const isLeaf = i === segments.length - 1
283
- const field = currentFields.find((f) => f.name === name)
284
- if (!field) return undefined
290
+ /** Select the item an index / id segment addresses, when data is available. */
291
+ function selectItem(value: unknown, segment: PathSegment): unknown {
292
+ if (!Array.isArray(value)) return undefined
293
+ if (segment.kind === 'index') return value[segment.index]
294
+ if (segment.kind === 'id') {
295
+ return value.find((item) => (item as { _id?: unknown } | null)?._id === segment.id)
296
+ }
297
+ return undefined
298
+ }
299
+
300
+ /**
301
+ * Walk instance-path segments against the schema, carrying the corresponding
302
+ * slice of form data alongside so `blocks` hops can read `_type`.
303
+ */
304
+ function resolveUploadConfig(
305
+ fields: readonly Field[],
306
+ segments: readonly PathSegment[],
307
+ value: unknown
308
+ ): UploadConfig | undefined {
309
+ const head = segments[0]
310
+ if (head == null || head.kind !== 'field') return undefined
285
311
 
286
- if (isLeaf) {
287
- return field.type === 'image' || field.type === 'file' ? field.upload : undefined
312
+ const field = fields.find((candidate) => candidate.name === head.name)
313
+ if (field == null) return undefined
314
+
315
+ const fieldValue = (value as Record<string, unknown> | undefined)?.[head.name]
316
+ const rest = segments.slice(1)
317
+
318
+ if (rest.length === 0) {
319
+ return field.type === 'image' || field.type === 'file' ? field.upload : undefined
320
+ }
321
+
322
+ if (field.type === 'group') {
323
+ return resolveUploadConfig(field.fields, rest, fieldValue)
324
+ }
325
+
326
+ if (field.type === 'array') {
327
+ // An item selector may be absent when the caller addresses the array's
328
+ // child declaration rather than one item; the child fields are the same.
329
+ const selector = rest[0]
330
+ if (selector?.kind === 'index' || selector?.kind === 'id') {
331
+ return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector))
288
332
  }
333
+ return resolveUploadConfig(field.fields, rest, undefined)
334
+ }
289
335
 
290
- if (field.type === 'group' || field.type === 'array') {
291
- currentFields = field.fields
292
- continue
336
+ if (field.type === 'blocks') {
337
+ const selector = rest[0]
338
+ const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest
339
+ const item =
340
+ selector?.kind === 'index' || selector?.kind === 'id'
341
+ ? selectItem(fieldValue, selector)
342
+ : undefined
343
+
344
+ const blockType = (item as { _type?: unknown } | undefined)?._type
345
+ if (typeof blockType === 'string') {
346
+ const block = field.blocks.find((candidate) => candidate.blockType === blockType)
347
+ return block == null ? undefined : resolveUploadConfig(block.fields, remainder, item)
293
348
  }
294
- if (field.type === 'blocks') {
295
- const remaining = segments[i + 1]
296
- const block = field.blocks.find((b) => b.fields.some((f) => f.name === remaining))
297
- if (!block) return undefined
298
- currentFields = block.fields
299
- continue
349
+
350
+ // No data to disambiguate. Try every block and accept a unique answer;
351
+ // two matches mean the path genuinely does not identify one declaration.
352
+ let found: UploadConfig | undefined
353
+ let matches = 0
354
+ for (const block of field.blocks) {
355
+ const result = resolveUploadConfig(block.fields, remainder, undefined)
356
+ if (result !== undefined) {
357
+ matches += 1
358
+ found = result
359
+ }
300
360
  }
301
- return undefined
361
+ return matches === 1 ? found : undefined
302
362
  }
363
+
303
364
  return undefined
304
365
  }
305
366