@byline/admin 4.3.0 → 4.4.1

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 (47) hide show
  1. package/dist/fields/array/array-field.d.ts +1 -8
  2. package/dist/fields/array/array-field.js +21 -27
  3. package/dist/fields/blocks/blocks-field.js +19 -17
  4. package/dist/fields/field-renderer.d.ts +1 -3
  5. package/dist/fields/field-renderer.js +3 -7
  6. package/dist/fields/file/file-field.d.ts +1 -3
  7. package/dist/fields/file/file-field.js +2 -2
  8. package/dist/fields/file/file-upload-field.js +2 -2
  9. package/dist/fields/group/group-field.d.ts +1 -7
  10. package/dist/fields/group/group-field.js +1 -2
  11. package/dist/fields/image/image-field.d.ts +1 -3
  12. package/dist/fields/image/image-field.js +2 -2
  13. package/dist/fields/image/image-upload-field.js +12 -3
  14. package/dist/forms/__probe-nested.test.node.d.ts +1 -0
  15. package/dist/forms/__probe-two.test.node.d.ts +1 -0
  16. package/dist/forms/form-context.d.ts +22 -2
  17. package/dist/forms/form-context.js +19 -4
  18. package/dist/forms/form-renderer.js +3 -1
  19. package/dist/forms/nested-path.d.ts +5 -1
  20. package/dist/forms/nested-path.js +84 -15
  21. package/dist/forms/pending-uploads.d.ts +6 -0
  22. package/dist/forms/pending-uploads.js +11 -0
  23. package/dist/forms/pending-uploads.test.node.d.ts +1 -0
  24. package/dist/forms/repeating-items.d.ts +16 -0
  25. package/dist/forms/repeating-items.js +28 -0
  26. package/dist/forms/repeating-items.test.node.d.ts +1 -0
  27. package/dist/forms/upload-executor.js +50 -29
  28. package/package.json +5 -5
  29. package/src/fields/array/array-field.tsx +28 -48
  30. package/src/fields/blocks/blocks-field.tsx +24 -27
  31. package/src/fields/code/code-field.tsx +1 -1
  32. package/src/fields/field-renderer.tsx +0 -7
  33. package/src/fields/file/file-field.tsx +4 -4
  34. package/src/fields/file/file-upload-field.tsx +9 -5
  35. package/src/fields/group/group-field.tsx +0 -8
  36. package/src/fields/image/image-field.tsx +4 -4
  37. package/src/fields/image/image-upload-field.tsx +24 -6
  38. package/src/forms/form-context.tsx +52 -4
  39. package/src/forms/form-renderer.tsx +3 -1
  40. package/src/forms/nested-path.test.node.ts +50 -1
  41. package/src/forms/nested-path.ts +106 -18
  42. package/src/forms/pending-uploads.test.node.ts +23 -0
  43. package/src/forms/pending-uploads.ts +22 -0
  44. package/src/forms/repeating-items.test.node.ts +36 -0
  45. package/src/forms/repeating-items.ts +48 -0
  46. package/src/forms/upload-executor.test.node.ts +248 -0
  47. package/src/forms/upload-executor.ts +94 -30
@@ -0,0 +1,48 @@
1
+ /** Return a canonical identity that is safe in an instance-path selector. */
2
+ export function repeatingItemId(item: unknown): string | undefined {
3
+ if (item == null || typeof item !== 'object' || !('_id' in item)) return undefined
4
+ const id = (item as { _id: unknown })._id
5
+ return typeof id === 'string' && id !== '' && !/[.[\]]/.test(id) ? id : undefined
6
+ }
7
+
8
+ /**
9
+ * Address an array/block item by stable identity when its id is path-safe.
10
+ * Positional fallback is retained for noncanonical create defaults and legacy
11
+ * adapter data that does not carry storage identity yet.
12
+ */
13
+ export function repeatingItemPath(parentPath: string, item: unknown, index: number): string {
14
+ const id = repeatingItemId(item)
15
+ return id != null ? `${parentPath}[id=${id}]` : `${parentPath}[${index}]`
16
+ }
17
+
18
+ export interface RepeatingItemMove<T> {
19
+ items: T[]
20
+ itemId: string
21
+ fromIndex: number
22
+ toIndex: number
23
+ }
24
+
25
+ /** Build one synchronized form-store move and its matching patch identity. */
26
+ export function moveRepeatingItems<T>(
27
+ items: readonly T[],
28
+ moveFromIndex: number,
29
+ moveToIndex: number
30
+ ): RepeatingItemMove<T> | null {
31
+ if (items.length === 0) return null
32
+
33
+ const fromIndex = Math.max(0, Math.min(moveFromIndex, items.length - 1))
34
+ const toIndex = Math.max(0, Math.min(moveToIndex, items.length - 1))
35
+ if (fromIndex === toIndex) return null
36
+
37
+ const source = items[fromIndex]
38
+ const moved = [...items]
39
+ const [item] = moved.splice(fromIndex, 1)
40
+ moved.splice(toIndex, 0, item as T)
41
+
42
+ return {
43
+ items: moved,
44
+ itemId: repeatingItemId(source) ?? String(fromIndex),
45
+ fromIndex,
46
+ toIndex,
47
+ }
48
+ }
@@ -56,6 +56,91 @@ 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: ['../caption', '/title'] },
103
+ },
104
+ ],
105
+ },
106
+ { name: 'caption', label: 'Caption', type: 'text' },
107
+ {
108
+ // Declared directly on the block, unlike `poster` above. This is
109
+ // the only depth at which `..` must leave the block entirely, so
110
+ // it is the only depth that can detect a scope miscount.
111
+ name: 'blockFile',
112
+ label: 'Block file',
113
+ type: 'file',
114
+ upload: { context: ['caption', '../title'] },
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ },
120
+ ]
121
+
122
+ /** Form state for `blocksFields`: a photoBlock then a videoBlock. */
123
+ const blocksFormValues = () => ({
124
+ title: 'Hello',
125
+ content: [
126
+ { _type: 'photoBlock', _id: 'blk-photo', gallery: [{ _id: 'gallery-photo' }] },
127
+ {
128
+ _type: 'videoBlock',
129
+ _id: 'blk-video',
130
+ gallery: [{ _id: 'gallery-video' }],
131
+ caption: 'Video caption',
132
+ },
133
+ ],
134
+ })
135
+
136
+ function imageUpload(name = 'p.png'): PendingUpload {
137
+ return {
138
+ file: new File(['x'], name, { type: 'image/png' }),
139
+ previewUrl: 'blob:mock',
140
+ collectionPath: 'pages',
141
+ }
142
+ }
143
+
59
144
  function pendingUpload(name = 'test.pdf'): PendingUpload {
60
145
  return {
61
146
  file: new File(['%PDF'], name, { type: 'application/pdf' }),
@@ -174,3 +259,166 @@ describe('executeUploads — context transmission', () => {
174
259
  expect(body.get('documentId')).toBeNull()
175
260
  })
176
261
  })
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Block resolution
265
+ //
266
+ // A form field path is an INSTANCE path — `content[1].gallery[0].poster` — and
267
+ // carries no block type, because a block item holds its own `_type`. Resolving
268
+ // an upload field inside a block therefore means reading that item.
269
+ // ---------------------------------------------------------------------------
270
+
271
+ describe('executeUploads — upload fields inside blocks', () => {
272
+ it('resolves upload.context for a field in the first block', async () => {
273
+ const { fn, bodies } = captureUploadField()
274
+ const uploads = new Map([['content[0].gallery[0].heroImage', imageUpload('h.png')]])
275
+
276
+ await executeUploads(uploads, fn, {
277
+ fields: blocksFields,
278
+ getFormValues: blocksFormValues,
279
+ })
280
+
281
+ expect(bodies[0]?.get('title')).toBe('Hello')
282
+ })
283
+
284
+ it('resolves upload.context for a field in a later block', async () => {
285
+ // Regression: block resolution used to match the first block declaring a
286
+ // field named by the next path segment. Both blocks declare `gallery`, so
287
+ // `photoBlock` won, `poster` was not found in it, and the declared context
288
+ // was dropped silently — no error, just a missing value the server-side
289
+ // beforeStore / afterStore hooks were relying on.
290
+ const { fn, bodies } = captureUploadField()
291
+ const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
292
+
293
+ await executeUploads(uploads, fn, {
294
+ fields: blocksFields,
295
+ getFormValues: blocksFormValues,
296
+ })
297
+
298
+ expect(bodies[0]?.get('title')).toBe('Hello')
299
+ })
300
+
301
+ it('addresses a block item by stable id as well as position', async () => {
302
+ const { fn, bodies } = captureUploadField()
303
+ const uploads = new Map([['content[id=blk-video].gallery[0].poster', imageUpload()]])
304
+
305
+ await executeUploads(uploads, fn, {
306
+ fields: blocksFields,
307
+ getFormValues: blocksFormValues,
308
+ })
309
+
310
+ expect(bodies[0]?.get('title')).toBe('Hello')
311
+ })
312
+
313
+ it('resolves sibling context through stable outer and inner item paths', async () => {
314
+ const { fn, bodies } = captureUploadField()
315
+ const uploads = new Map([
316
+ ['content[id=blk-video].gallery[id=gallery-video].poster', imageUpload()],
317
+ ])
318
+
319
+ await executeUploads(uploads, fn, {
320
+ fields: blocksFields,
321
+ getFormValues: blocksFormValues,
322
+ })
323
+
324
+ expect(bodies[0]?.get('caption')).toBe('Video caption')
325
+ expect(bodies[0]?.get('title')).toBe('Hello')
326
+ })
327
+
328
+ it('climbs out of the block to the document root for an upload declared on the block', async () => {
329
+ // The scope boundary, and the one case that can catch a miscount.
330
+ //
331
+ // `poster` sits inside `gallery[]`, so its `../caption` climbs from the
332
+ // array item to the *block item* — a hop that stays inside the block.
333
+ // `blockFile` sits on the block itself, so `../title` has to leave the
334
+ // block and land at the document root.
335
+ //
336
+ // Those two behave differently under a path that carries a segment which
337
+ // addresses nothing. An abandoned experiment qualified instance paths with
338
+ // the block type (`content[1].videoBlock.blockFile`), and because
339
+ // `resolveContextPath` counts every dotted segment as one scope,`..`
340
+ // stopped one level short: `../title` resolved to `content[1].title`
341
+ // inside the block rather than the root, and arrived empty. The
342
+ // inside-the-block case absorbed the extra segment and kept passing —
343
+ // which is exactly why it cannot stand in for this one.
344
+ const { fn, bodies } = captureUploadField()
345
+ const uploads = new Map([['content[1].blockFile', pendingUpload()]])
346
+
347
+ await executeUploads(uploads, fn, {
348
+ fields: blocksFields,
349
+ getFormValues: blocksFormValues,
350
+ })
351
+
352
+ // Root-level `title`, not the block's own `caption` scope.
353
+ expect(bodies[0]?.get('title')).toBe('Hello')
354
+ // The sibling still resolves, so a failure above is the climb specifically
355
+ // and not block resolution having gone wrong generally.
356
+ expect(bodies[0]?.get('caption')).toBe('Video caption')
357
+ })
358
+
359
+ it('still resolves when the addressed block item is missing from form state', async () => {
360
+ // Form state can lag a pending upload. The item is what supplies `_type`,
361
+ // so its absence drops us to the unique-match fallback — but the *field*
362
+ // is a declaration, and `poster` is declared in exactly one block, so the
363
+ // answer is unchanged. Staleness costs nothing here; genuine ambiguity
364
+ // would still return nothing (see the ambiguous case below).
365
+ const { fn, bodies } = captureUploadField()
366
+ const uploads = new Map([['content[7].gallery[0].poster', imageUpload()]])
367
+
368
+ await executeUploads(uploads, fn, {
369
+ fields: blocksFields,
370
+ getFormValues: blocksFormValues,
371
+ })
372
+
373
+ expect(bodies[0]?.get('title')).toBe('Hello')
374
+ expect(bodies[0]?.get('fieldPath')).toBe('content[7].gallery[0].poster')
375
+ })
376
+
377
+ it('falls back to a unique match when form values cannot disambiguate', async () => {
378
+ // Without data the block type is unknowable from the path. `poster` is
379
+ // declared in exactly one block, so the answer is still unambiguous.
380
+ const { fn, bodies } = captureUploadField()
381
+ const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
382
+
383
+ await executeUploads(uploads, fn, {
384
+ fields: blocksFields,
385
+ getFormValues: () => ({ title: 'Hello' }),
386
+ })
387
+
388
+ expect(bodies[0]?.get('title')).toBe('Hello')
389
+ })
390
+
391
+ it('sends no context when the path genuinely identifies no single declaration', async () => {
392
+ // Same leaf name in both blocks and no data to choose between them. A
393
+ // guess would be wrong half the time, so nothing is sent.
394
+ const ambiguous: Field[] = [
395
+ { name: 'title', label: 'Title', type: 'text' },
396
+ {
397
+ name: 'content',
398
+ label: 'Content',
399
+ type: 'blocks',
400
+ blocks: [
401
+ {
402
+ blockType: 'aBlock',
403
+ fields: [
404
+ { name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
405
+ ],
406
+ },
407
+ {
408
+ blockType: 'bBlock',
409
+ fields: [
410
+ { name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ ]
416
+
417
+ const { fn, bodies } = captureUploadField()
418
+ const uploads = new Map([['content[1].shared', imageUpload()]])
419
+
420
+ await executeUploads(uploads, fn, { fields: ambiguous, getFormValues: () => ({ title: 'x' }) })
421
+
422
+ expect(bodies[0]?.get('title')).toBeNull()
423
+ })
424
+ })
@@ -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
@@ -212,7 +216,10 @@ function resolveContextPath(fieldPath: string, contextPath: string): string | un
212
216
  }
213
217
 
214
218
  // Scope = the upload field's containing segments (dot-split keeps array
215
- // indices attached to their segment: `files[2]` stays one hop).
219
+ // selectors attached to their segment: `files[id=x]` stays one hop).
220
+ // This relies on every dotted form-path segment being a real data scope. If
221
+ // the grammar ever adds descriptive/non-navigating segments, classify and
222
+ // remove them before counting `..` hops rather than changing parent scope.
216
223
  const scope = fieldPath.split('.')
217
224
  scope.pop() // drop the upload field's own leaf segment
218
225
 
@@ -263,43 +270,100 @@ function isRelationEnvelope(value: unknown): value is { targetDocumentId: string
263
270
 
264
271
  /**
265
272
  * 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).
273
+ * field path, walking `group` / `array` / `blocks` structures.
274
+ *
275
+ * The path is an *instance* path (`content[1].gallery[0].poster`), so it
276
+ * carries no block type a block item's type lives on the item itself, as
277
+ * `_type`. Passing `formValues` therefore lets a `blocks` hop be resolved
278
+ * exactly: read the addressed item, take its `_type`, descend into that
279
+ * block. Without form values the block type is genuinely unknowable from the
280
+ * path, so every block is tried and the result is accepted only when exactly
281
+ * one resolves — ambiguity returns `undefined` rather than a guess.
272
282
  */
273
283
  function findUploadFieldByPath(
274
284
  fields: readonly Field[],
275
- fieldPath: string
285
+ fieldPath: string,
286
+ formValues?: Record<string, any>
276
287
  ): UploadConfig | undefined {
277
- const segments = fieldPath.split('.').map((s) => s.replace(/\[\d+\]$/, ''))
288
+ const parsed = parseInstancePath(fieldPath)
289
+ if (!parsed.ok) return undefined
290
+ return resolveUploadConfig(fields, parsed.segments, formValues)
291
+ }
278
292
 
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
293
+ /** Select the item an index / id segment addresses, when data is available. */
294
+ function selectItem(value: unknown, segment: PathSegment): unknown {
295
+ if (!Array.isArray(value)) return undefined
296
+ if (segment.kind === 'index') return value[segment.index]
297
+ if (segment.kind === 'id') {
298
+ return value.find((item) => (item as { _id?: unknown } | null)?._id === segment.id)
299
+ }
300
+ return undefined
301
+ }
302
+
303
+ /**
304
+ * Walk instance-path segments against the schema, carrying the corresponding
305
+ * slice of form data alongside so `blocks` hops can read `_type`.
306
+ */
307
+ function resolveUploadConfig(
308
+ fields: readonly Field[],
309
+ segments: readonly PathSegment[],
310
+ value: unknown
311
+ ): UploadConfig | undefined {
312
+ const head = segments[0]
313
+ if (head == null || head.kind !== 'field') return undefined
285
314
 
286
- if (isLeaf) {
287
- return field.type === 'image' || field.type === 'file' ? field.upload : undefined
315
+ const field = fields.find((candidate) => candidate.name === head.name)
316
+ if (field == null) return undefined
317
+
318
+ const fieldValue = (value as Record<string, unknown> | undefined)?.[head.name]
319
+ const rest = segments.slice(1)
320
+
321
+ if (rest.length === 0) {
322
+ return field.type === 'image' || field.type === 'file' ? field.upload : undefined
323
+ }
324
+
325
+ if (field.type === 'group') {
326
+ return resolveUploadConfig(field.fields, rest, fieldValue)
327
+ }
328
+
329
+ if (field.type === 'array') {
330
+ // An item selector may be absent when the caller addresses the array's
331
+ // child declaration rather than one item; the child fields are the same.
332
+ const selector = rest[0]
333
+ if (selector?.kind === 'index' || selector?.kind === 'id') {
334
+ return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector))
288
335
  }
336
+ return resolveUploadConfig(field.fields, rest, undefined)
337
+ }
289
338
 
290
- if (field.type === 'group' || field.type === 'array') {
291
- currentFields = field.fields
292
- continue
339
+ if (field.type === 'blocks') {
340
+ const selector = rest[0]
341
+ const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest
342
+ const item =
343
+ selector?.kind === 'index' || selector?.kind === 'id'
344
+ ? selectItem(fieldValue, selector)
345
+ : undefined
346
+
347
+ const blockType = (item as { _type?: unknown } | undefined)?._type
348
+ if (typeof blockType === 'string') {
349
+ const block = field.blocks.find((candidate) => candidate.blockType === blockType)
350
+ return block == null ? undefined : resolveUploadConfig(block.fields, remainder, item)
293
351
  }
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
352
+
353
+ // No data to disambiguate. Try every block and accept a unique answer;
354
+ // two matches mean the path genuinely does not identify one declaration.
355
+ let found: UploadConfig | undefined
356
+ let matches = 0
357
+ for (const block of field.blocks) {
358
+ const result = resolveUploadConfig(block.fields, remainder, undefined)
359
+ if (result !== undefined) {
360
+ matches += 1
361
+ found = result
362
+ }
300
363
  }
301
- return undefined
364
+ return matches === 1 ? found : undefined
302
365
  }
366
+
303
367
  return undefined
304
368
  }
305
369