@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
@@ -12,6 +12,7 @@ import type { Field, FieldAdminConfig, GroupField as GroupFieldType } from '@byl
12
12
  import { ErrorText } from '@byline/ui/react'
13
13
  import cx from 'classnames'
14
14
 
15
+ import { sliceFieldAdmin } from '../../fields/field-admin'
15
16
  import { placeholderForField } from '../../fields/field-helpers'
16
17
  import { FieldRenderer } from '../../fields/field-renderer'
17
18
  import { useFieldError } from '../../forms/form-context'
@@ -33,11 +34,15 @@ interface GroupFieldProps {
33
34
  defaultValue: any
34
35
  path: string
35
36
  /**
36
- * Collection path forwarded to upload-capable child fields (`file` / `image`),
37
- * which need it to reach the `/upload` endpoint. Without it those fields fall
38
- * back to their empty placeholder and never render an upload widget.
37
+ * Threaded to child fields governs only the *drag* affordance of any
38
+ * `array` children (structural add/remove always renders; see ArrayField).
39
+ * Defaults to `true` (conservative): arrays inside plain schema groups
40
+ * stay drag-free. `BlocksField` passes `false` on its synthesized group so
41
+ * arrays directly inside blocks are fully sortable — safe because each
42
+ * `DraggableSortable` is an independent DndContext with grip-scoped
43
+ * listeners.
39
44
  */
40
- collectionPath?: string
45
+ disableSorting?: boolean
41
46
  /**
42
47
  * Active content locale, forwarded to child fields so localized widgets
43
48
  * nested inside the group (e.g. a `localized` richText) can render their
@@ -46,10 +51,13 @@ interface GroupFieldProps {
46
51
  contentLocale?: string
47
52
  /**
48
53
  * Per-child-field admin overrides (`components` slots, richtext `editor`),
49
- * keyed by child field name. Threaded by `BlocksField` from the site-wide
50
- * `ClientConfig.blockAdmin` registry so block children can take per-field
51
- * admin config; plain groups receive none today (their children inherit
52
- * site-wide defaults).
54
+ * keyed by dotted, index-free schema paths relative to this group
55
+ * ('caption', 'faq.answer'). Threaded by `BlocksField` from the site-wide
56
+ * `ClientConfig.blockAdmin` registry (block children render through a
57
+ * synthesized group) and by `FieldRenderer` for plain schema groups, whose
58
+ * map arrives pre-sliced from the collection admin config. Exact-name
59
+ * entries apply to the child itself; deeper entries are re-sliced and
60
+ * threaded on (see `sliceFieldAdmin`).
53
61
  */
54
62
  fieldAdmin?: Record<string, FieldAdminConfig>
55
63
  }
@@ -58,7 +66,7 @@ export const GroupField = ({
58
66
  field,
59
67
  defaultValue,
60
68
  path,
61
- collectionPath,
69
+ disableSorting = true,
62
70
  contentLocale,
63
71
  fieldAdmin,
64
72
  }: GroupFieldProps) => {
@@ -100,11 +108,11 @@ export const GroupField = ({
100
108
  field={innerField}
101
109
  defaultValue={groupData[innerField.name]}
102
110
  basePath={path}
103
- disableSorting={true}
104
- collectionPath={collectionPath}
111
+ disableSorting={disableSorting}
105
112
  contentLocale={contentLocale}
106
113
  components={fieldAdmin?.[innerField.name]?.components}
107
114
  editor={fieldAdmin?.[innerField.name]?.editor}
115
+ fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}
108
116
  />
109
117
  )
110
118
  })}
@@ -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.
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { type ReactNode, useState } from 'react'
9
+ import { type CSSProperties, type ReactNode, type Ref, useState } from 'react'
10
10
 
11
11
  import { useTranslation } from '@byline/i18n/react'
12
12
  import { ChevronDownIcon, GripperVerticalIcon, useSortable } from '@byline/ui/react'
@@ -15,44 +15,60 @@ import cx from 'classnames'
15
15
  import { DraggableContextMenu } from './draggable-context-menu'
16
16
  import styles from './sortable-item.module.css'
17
17
 
18
- export const SortableItem = ({
19
- id,
20
- label,
21
- children,
22
- onAddBelow,
23
- onRemove,
24
- }: {
25
- id: string
18
+ // ---------------------------------------------------------------------------
19
+ // Item chrome for repeating-structure entries (array items, block instances):
20
+ // a header carrying the label, the add-below/remove context menu, and a
21
+ // collapse toggle, above the item's rendered fields.
22
+ //
23
+ // Two variants share the frame:
24
+ // - `SortableItem` — adds the dnd-kit grip (drag handle). Must render
25
+ // inside a `DraggableSortable` (it calls `useSortable`).
26
+ // - `StaticItem` — no grip, no dnd hook. For contexts where drag is
27
+ // disabled but structural editing (add/remove/collapse) must remain,
28
+ // e.g. arrays nested inside another array's items.
29
+ //
30
+ // They are separate components (not a boolean prop on one component)
31
+ // because `useSortable` is a hook — it cannot be called conditionally, and
32
+ // it throws outside a DndContext.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface ItemFrameProps {
26
36
  label: ReactNode
27
37
  children: ReactNode
28
38
  onAddBelow?: () => void
29
39
  onRemove?: () => void
30
- }) => {
31
- const { t } = useTranslation('byline-admin')
32
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
33
- id,
34
- transition: {
35
- duration: 250,
36
- easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
37
- },
38
- })
40
+ /** Drag handle slot — rendered leading the header when provided. */
41
+ grip?: ReactNode
42
+ rootRef?: Ref<HTMLDivElement>
43
+ style?: CSSProperties
44
+ dragging?: boolean
45
+ /** Extra root class, e.g. the static variant marker. */
46
+ rootClassName?: string
47
+ }
39
48
 
49
+ const ItemFrame = ({
50
+ label,
51
+ children,
52
+ onAddBelow,
53
+ onRemove,
54
+ grip,
55
+ rootRef,
56
+ style,
57
+ dragging = false,
58
+ rootClassName,
59
+ }: ItemFrameProps) => {
60
+ const { t } = useTranslation('byline-admin')
40
61
  const [collapsed, setCollapsed] = useState(false)
41
62
 
42
- const style = {
43
- transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
44
- transition,
45
- zIndex: isDragging ? 10 : 'auto',
46
- }
47
-
48
63
  return (
49
64
  <div
50
- ref={setNodeRef}
65
+ ref={rootRef}
51
66
  style={style}
52
67
  className={cx(
53
68
  'byline-sortable',
54
69
  styles.root,
55
- isDragging && ['byline-sortable-dragging', styles.dragging],
70
+ rootClassName,
71
+ dragging && ['byline-sortable-dragging', styles.dragging],
56
72
  collapsed && ['byline-sortable-collapsed', styles.collapsed]
57
73
  )}
58
74
  >
@@ -63,14 +79,7 @@ export const SortableItem = ({
63
79
  !collapsed && ['byline-sortable-header-expanded', styles['header-expanded']]
64
80
  )}
65
81
  >
66
- <button
67
- type="button"
68
- className={cx('byline-sortable-grip', styles.grip)}
69
- {...attributes}
70
- {...listeners}
71
- >
72
- <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
73
- </button>
82
+ {grip}
74
83
  <div className={cx('byline-sortable-label', styles.label)}>{label}</div>
75
84
  <DraggableContextMenu onAddBelow={onAddBelow} onRemove={onRemove} />
76
85
  <button
@@ -104,3 +113,75 @@ export const SortableItem = ({
104
113
  </div>
105
114
  )
106
115
  }
116
+
117
+ export const SortableItem = ({
118
+ id,
119
+ label,
120
+ children,
121
+ onAddBelow,
122
+ onRemove,
123
+ }: {
124
+ id: string
125
+ label: ReactNode
126
+ children: ReactNode
127
+ onAddBelow?: () => void
128
+ onRemove?: () => void
129
+ }) => {
130
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
131
+ id,
132
+ transition: {
133
+ duration: 250,
134
+ easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
135
+ },
136
+ })
137
+
138
+ const style = {
139
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
140
+ transition,
141
+ zIndex: isDragging ? 10 : 'auto',
142
+ }
143
+
144
+ return (
145
+ <ItemFrame
146
+ label={label}
147
+ onAddBelow={onAddBelow}
148
+ onRemove={onRemove}
149
+ rootRef={setNodeRef}
150
+ style={style}
151
+ dragging={isDragging}
152
+ grip={
153
+ <button
154
+ type="button"
155
+ className={cx('byline-sortable-grip', styles.grip)}
156
+ {...attributes}
157
+ {...listeners}
158
+ >
159
+ <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
160
+ </button>
161
+ }
162
+ >
163
+ {children}
164
+ </ItemFrame>
165
+ )
166
+ }
167
+
168
+ export const StaticItem = ({
169
+ label,
170
+ children,
171
+ onAddBelow,
172
+ onRemove,
173
+ }: {
174
+ label: ReactNode
175
+ children: ReactNode
176
+ onAddBelow?: () => void
177
+ onRemove?: () => void
178
+ }) => (
179
+ <ItemFrame
180
+ label={label}
181
+ onAddBelow={onAddBelow}
182
+ onRemove={onRemove}
183
+ rootClassName="byline-sortable-static"
184
+ >
185
+ {children}
186
+ </ItemFrame>
187
+ )
@@ -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))
@@ -316,7 +336,15 @@ export const FormProvider = ({
316
336
  const getPatches = useCallback(() => patchesRef.current, [])
317
337
  const appendPatch = useCallback(
318
338
  (patch: DocumentPatch) => {
319
- patchesRef.current = [...patchesRef.current, patch]
339
+ // Snapshot the patch at append time. Structural patches (array.insert,
340
+ // block add) carry item objects that are ALSO placed into the form
341
+ // store — and `setNestedValue` mutates store nodes in place, so a
342
+ // later nested write inside the item (e.g. adding an array item to a
343
+ // block added this session) would silently rewrite the queued patch.
344
+ // Serialized at save time, the block insert would then already contain
345
+ // the array items AND the array.insert patches would re-add them —
346
+ // duplicating items server-side (caught by e2e/array-in-block.spec.ts).
347
+ patchesRef.current = [...patchesRef.current, structuredClone(patch)]
320
348
  // Mark a generic dirty flag so hasChanges() becomes true even
321
349
  // for patches that don't correspond to a specific field.set.
322
350
  dirtyFields.current.add('__patch__')
@@ -654,6 +682,7 @@ export const FormProvider = ({
654
682
  <FormContext.Provider
655
683
  value={{
656
684
  documentId,
685
+ collectionPath,
657
686
  setFieldValue,
658
687
  setFieldStore,
659
688
  getFieldValue,
@@ -24,6 +24,7 @@ import { useTranslation } from '@byline/i18n/react'
24
24
  import { Alert, Button, ComboButton } from '@byline/ui/react'
25
25
  import cx from 'classnames'
26
26
 
27
+ import { sliceFieldAdmin } from '../fields/field-admin'
27
28
  import { FieldRenderer } from '../fields/field-renderer'
28
29
  import { useBylineFieldServices } from '../fields/field-services-context'
29
30
  import { AdminGroup } from '../presentation/group'
@@ -484,10 +485,10 @@ const FormContent = ({
484
485
  key={field.name}
485
486
  field={field}
486
487
  defaultValue={initialData?.fields?.[field.name]}
487
- collectionPath={collectionPath}
488
488
  contentLocale={contentLocale}
489
489
  components={adminConfig?.fields?.[field.name]?.components}
490
490
  editor={adminConfig?.fields?.[field.name]?.editor}
491
+ fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
491
492
  />
492
493
  )
493
494
  }
@@ -776,6 +777,7 @@ export const FormRenderer = ({
776
777
  key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
777
778
  initialData={initialData}
778
779
  documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
780
+ collectionPath={collectionPath ?? null}
779
781
  >
780
782
  <FormContent
781
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
+ })