@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.
- package/dist/fields/array/array-field.d.ts +1 -8
- package/dist/fields/array/array-field.js +21 -27
- package/dist/fields/blocks/blocks-field.js +19 -17
- package/dist/fields/field-renderer.d.ts +1 -3
- package/dist/fields/field-renderer.js +3 -7
- package/dist/fields/file/file-field.d.ts +1 -3
- package/dist/fields/file/file-field.js +2 -2
- package/dist/fields/file/file-upload-field.js +2 -2
- package/dist/fields/group/group-field.d.ts +1 -7
- package/dist/fields/group/group-field.js +1 -2
- package/dist/fields/image/image-field.d.ts +1 -3
- package/dist/fields/image/image-field.js +2 -2
- package/dist/fields/image/image-upload-field.js +12 -3
- package/dist/forms/__probe-nested.test.node.d.ts +1 -0
- package/dist/forms/__probe-two.test.node.d.ts +1 -0
- package/dist/forms/form-context.d.ts +22 -2
- package/dist/forms/form-context.js +19 -4
- package/dist/forms/form-renderer.js +3 -1
- package/dist/forms/nested-path.d.ts +5 -1
- package/dist/forms/nested-path.js +84 -15
- package/dist/forms/pending-uploads.d.ts +6 -0
- package/dist/forms/pending-uploads.js +11 -0
- package/dist/forms/pending-uploads.test.node.d.ts +1 -0
- package/dist/forms/repeating-items.d.ts +16 -0
- package/dist/forms/repeating-items.js +28 -0
- package/dist/forms/repeating-items.test.node.d.ts +1 -0
- package/dist/forms/upload-executor.js +50 -29
- package/package.json +5 -5
- package/src/fields/array/array-field.tsx +28 -48
- package/src/fields/blocks/blocks-field.tsx +24 -27
- package/src/fields/code/code-field.tsx +1 -1
- package/src/fields/field-renderer.tsx +0 -7
- package/src/fields/file/file-field.tsx +4 -4
- package/src/fields/file/file-upload-field.tsx +9 -5
- package/src/fields/group/group-field.tsx +0 -8
- package/src/fields/image/image-field.tsx +4 -4
- package/src/fields/image/image-upload-field.tsx +24 -6
- package/src/forms/form-context.tsx +52 -4
- package/src/forms/form-renderer.tsx +3 -1
- package/src/forms/nested-path.test.node.ts +50 -1
- package/src/forms/nested-path.ts +106 -18
- package/src/forms/pending-uploads.test.node.ts +23 -0
- package/src/forms/pending-uploads.ts +22 -0
- package/src/forms/repeating-items.test.node.ts +36 -0
- package/src/forms/repeating-items.ts +48 -0
- package/src/forms/upload-executor.test.node.ts +248 -0
- 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 &&
|
|
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
|
-
//
|
|
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.
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
|
288
|
+
const parsed = parseInstancePath(fieldPath)
|
|
289
|
+
if (!parsed.ok) return undefined
|
|
290
|
+
return resolveUploadConfig(fields, parsed.segments, formValues)
|
|
291
|
+
}
|
|
278
292
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|