@byline/admin 3.17.1 → 3.19.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.
- package/dist/fields/file/file-field.js +8 -2
- package/dist/fields/image/image-field.js +8 -2
- package/dist/forms/form-context.d.ts +13 -1
- package/dist/forms/form-context.js +2 -1
- package/dist/forms/form-renderer.js +18 -1
- package/dist/forms/path-widget.d.ts +19 -1
- package/dist/forms/path-widget.js +10 -6
- package/dist/forms/upload-executor.d.ts +27 -3
- package/dist/forms/upload-executor.js +83 -8
- package/dist/forms/upload-executor.test.node.d.ts +8 -0
- package/dist/modules/admin-account/errors.d.ts +2 -2
- package/dist/modules/admin-activity/abilities.d.ts +1 -1
- package/dist/modules/admin-permissions/abilities.d.ts +2 -2
- package/dist/modules/admin-permissions/errors.d.ts +2 -2
- package/dist/modules/admin-permissions/schemas.d.ts +4 -4
- package/dist/modules/admin-roles/abilities.d.ts +4 -4
- package/dist/modules/admin-roles/errors.d.ts +4 -4
- package/dist/modules/admin-users/abilities.d.ts +5 -5
- package/dist/modules/admin-users/errors.d.ts +5 -5
- package/dist/modules/admin-users/schemas.d.ts +6 -6
- package/dist/vendor/noble-argon2/blake2.js +17 -17
- package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +4 -18
- package/package.json +16 -16
- package/src/fields/file/file-field.tsx +16 -2
- package/src/fields/image/image-field.tsx +16 -2
- package/src/forms/form-context.tsx +14 -0
- package/src/forms/form-renderer.tsx +27 -0
- package/src/forms/path-widget.test.tsx +33 -0
- package/src/forms/path-widget.tsx +33 -5
- package/src/forms/upload-executor.test.node.ts +176 -0
- package/src/forms/upload-executor.ts +209 -14
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* but only uploaded when the user clicks Save.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { StoredFileValue } from '@byline/core'
|
|
17
|
+
import type { Field, StoredFileValue, UploadConfig } from '@byline/core'
|
|
18
18
|
|
|
19
|
+
import { get as getNestedValue } from './nested-path'
|
|
19
20
|
import type { UploadFieldFn } from '../fields/field-services-types'
|
|
20
21
|
import type { PendingUpload } from './form-context'
|
|
21
22
|
|
|
@@ -37,6 +38,29 @@ export interface ExecuteUploadsResult {
|
|
|
37
38
|
allSucceeded: boolean
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Optional document context threaded from the form renderer so upload
|
|
43
|
+
* requests carry the state that server-side `beforeStore` / `afterStore`
|
|
44
|
+
* hooks need (see `UploadConfig.context` in `@byline/core`).
|
|
45
|
+
*/
|
|
46
|
+
export interface UploadExecutionContext {
|
|
47
|
+
/**
|
|
48
|
+
* The persisted document id (edit mode). Posted as `documentId` on every
|
|
49
|
+
* upload request; omitted while the document is unsaved (create mode).
|
|
50
|
+
*/
|
|
51
|
+
documentId?: string
|
|
52
|
+
/**
|
|
53
|
+
* The collection's schema fields — used to locate each upload field's
|
|
54
|
+
* `upload.context` declaration by walking the pending upload's field path.
|
|
55
|
+
*/
|
|
56
|
+
fields?: readonly Field[]
|
|
57
|
+
/**
|
|
58
|
+
* Snapshot accessor for the live form values, resolved lazily per upload
|
|
59
|
+
* so context reflects the state at the moment the request is built.
|
|
60
|
+
*/
|
|
61
|
+
getFormValues?: () => Record<string, any>
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* Execute all pending uploads sequentially.
|
|
42
66
|
* Returns a result object with successful uploads and any errors.
|
|
@@ -44,25 +68,21 @@ export interface ExecuteUploadsResult {
|
|
|
44
68
|
* @param pendingUploads - Map of field path to PendingUpload
|
|
45
69
|
* @param uploadField - Host-provided upload transport (resolved via
|
|
46
70
|
* `useBylineFieldServices()` in the calling React tree)
|
|
71
|
+
* @param executionContext - Optional document/form context appended to each
|
|
72
|
+
* upload request (documentId, `upload.context` values)
|
|
47
73
|
* @returns Promise resolving to ExecuteUploadsResult
|
|
48
74
|
*/
|
|
49
75
|
export async function executeUploads(
|
|
50
76
|
pendingUploads: Map<string, PendingUpload>,
|
|
51
|
-
uploadField: UploadFieldFn
|
|
77
|
+
uploadField: UploadFieldFn,
|
|
78
|
+
executionContext?: UploadExecutionContext
|
|
52
79
|
): Promise<ExecuteUploadsResult> {
|
|
53
80
|
const results: UploadResult[] = []
|
|
54
81
|
const successful = new Map<string, StoredFileValue>()
|
|
55
82
|
const errors = new Map<string, string>()
|
|
56
83
|
|
|
57
84
|
for (const [fieldPath, upload] of pendingUploads.entries()) {
|
|
58
|
-
const formData =
|
|
59
|
-
formData.append('file', upload.file)
|
|
60
|
-
// Tell the server which upload-capable field this file belongs to.
|
|
61
|
-
// With per-field upload config a collection can have multiple
|
|
62
|
-
// image/file fields, each with its own constraints; the server's
|
|
63
|
-
// unique-default fallback covers the single-field case but rejects
|
|
64
|
-
// multi-field collections without an explicit selector.
|
|
65
|
-
formData.append('field', uploadFieldName(fieldPath))
|
|
85
|
+
const formData = buildUploadFormData(fieldPath, upload, executionContext)
|
|
66
86
|
|
|
67
87
|
try {
|
|
68
88
|
// Pass createDocument=false — we're uploading for an embedded field,
|
|
@@ -94,6 +114,61 @@ export async function executeUploads(
|
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Compose the multipart body for one pending upload.
|
|
119
|
+
*
|
|
120
|
+
* Beyond the file itself, the request carries:
|
|
121
|
+
* - `field` — leaf name of the upload-capable field (server-side
|
|
122
|
+
* resolver matches upload fields by leaf name at any
|
|
123
|
+
* nesting depth, so leaf names must be unique among a
|
|
124
|
+
* collection's upload-capable fields).
|
|
125
|
+
* - `fieldPath` — the full form path (e.g.
|
|
126
|
+
* `files[2].filesGroup.publicationFile`), so hooks can
|
|
127
|
+
* distinguish array items.
|
|
128
|
+
* - `documentId` — the persisted document id (edit mode only).
|
|
129
|
+
* - one entry per resolved `upload.context` path (see
|
|
130
|
+
* `UploadConfig.context` in `@byline/core` for path semantics and
|
|
131
|
+
* serialisation rules).
|
|
132
|
+
*/
|
|
133
|
+
function buildUploadFormData(
|
|
134
|
+
fieldPath: string,
|
|
135
|
+
upload: PendingUpload,
|
|
136
|
+
executionContext?: UploadExecutionContext
|
|
137
|
+
): FormData {
|
|
138
|
+
const formData = new FormData()
|
|
139
|
+
formData.append('file', upload.file)
|
|
140
|
+
// Tell the server which upload-capable field this file belongs to.
|
|
141
|
+
// With per-field upload config a collection can have multiple
|
|
142
|
+
// image/file fields, each with its own constraints; the server's
|
|
143
|
+
// unique-default fallback covers the single-field case but rejects
|
|
144
|
+
// multi-field collections without an explicit selector.
|
|
145
|
+
formData.append('field', uploadFieldName(fieldPath))
|
|
146
|
+
formData.append('fieldPath', fieldPath)
|
|
147
|
+
|
|
148
|
+
if (executionContext?.documentId) {
|
|
149
|
+
formData.append('documentId', executionContext.documentId)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const contextPaths =
|
|
153
|
+
executionContext?.fields != null
|
|
154
|
+
? findUploadFieldByPath(executionContext.fields, fieldPath)?.context
|
|
155
|
+
: undefined
|
|
156
|
+
|
|
157
|
+
if (contextPaths && contextPaths.length > 0 && executionContext?.getFormValues) {
|
|
158
|
+
const formValues = executionContext.getFormValues()
|
|
159
|
+
for (const contextPath of contextPaths) {
|
|
160
|
+
const resolvedPath = resolveContextPath(fieldPath, contextPath)
|
|
161
|
+
if (resolvedPath === undefined) continue
|
|
162
|
+
const value = resolvedPath === '' ? formValues : getNestedValue(formValues, resolvedPath)
|
|
163
|
+
const serialized = serializeContextValue(value)
|
|
164
|
+
if (serialized === undefined) continue
|
|
165
|
+
formData.append(leafName(contextPath), serialized)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return formData
|
|
170
|
+
}
|
|
171
|
+
|
|
97
172
|
/**
|
|
98
173
|
* Extract the leaf field name from a `fieldPath`. Top-level upload
|
|
99
174
|
* fields (`'image'`, `'avatar'`) pass through unchanged; nested paths
|
|
@@ -107,6 +182,127 @@ function uploadFieldName(fieldPath: string): string {
|
|
|
107
182
|
return dot === -1 ? fieldPath : fieldPath.slice(dot + 1)
|
|
108
183
|
}
|
|
109
184
|
|
|
185
|
+
/** Leaf segment of a context path: `'../a.b'` → `'b'`, `'/x'` → `'x'`. */
|
|
186
|
+
function leafName(contextPath: string): string {
|
|
187
|
+
const segments = contextPath.split('/').pop() ?? contextPath
|
|
188
|
+
const dot = segments.lastIndexOf('.')
|
|
189
|
+
return dot === -1 ? segments : segments.slice(dot + 1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve an `upload.context` path declaration against the upload field's
|
|
194
|
+
* position in the form, filesystem-style. The upload field is treated as a
|
|
195
|
+
* "file" living in the directory formed by its containing scope:
|
|
196
|
+
*
|
|
197
|
+
* fieldPath `files[2].filesGroup.publicationFile` → scope
|
|
198
|
+
* `['files[2]', 'filesGroup']`
|
|
199
|
+
*
|
|
200
|
+
* - `'language'` / `'./language'` → `files[2].filesGroup.language`
|
|
201
|
+
* - `'../label'` → `files[2].label`
|
|
202
|
+
* - `'/serialNumber'` → `serialNumber` (document root)
|
|
203
|
+
*
|
|
204
|
+
* Returns the dotted form path to read, `''` for the form root itself, or
|
|
205
|
+
* `undefined` when `../` climbs past the root (declaration bug — the value
|
|
206
|
+
* is skipped rather than mis-resolved).
|
|
207
|
+
*/
|
|
208
|
+
function resolveContextPath(fieldPath: string, contextPath: string): string | undefined {
|
|
209
|
+
// Root-absolute: strip the slash, done.
|
|
210
|
+
if (contextPath.startsWith('/')) {
|
|
211
|
+
return contextPath.slice(1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Scope = the upload field's containing segments (dot-split keeps array
|
|
215
|
+
// indices attached to their segment: `files[2]` stays one hop).
|
|
216
|
+
const scope = fieldPath.split('.')
|
|
217
|
+
scope.pop() // drop the upload field's own leaf segment
|
|
218
|
+
|
|
219
|
+
const parts = contextPath.split('/')
|
|
220
|
+
const leaf = parts.pop() ?? ''
|
|
221
|
+
for (const part of parts) {
|
|
222
|
+
if (part === '.' || part === '') continue
|
|
223
|
+
if (part === '..') {
|
|
224
|
+
if (scope.length === 0) return undefined
|
|
225
|
+
scope.pop()
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
// A directory-style intermediate segment (`a/b`) descends.
|
|
229
|
+
scope.push(part)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return scope.length === 0 ? leaf : `${scope.join('.')}${leaf ? `.${leaf}` : ''}`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Serialise a resolved form value for the multipart `fields` bag.
|
|
237
|
+
* See `UploadConfig.context` for the contract.
|
|
238
|
+
*/
|
|
239
|
+
function serializeContextValue(value: unknown): string | undefined {
|
|
240
|
+
if (value == null) return undefined
|
|
241
|
+
if (typeof value === 'string') return value
|
|
242
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
243
|
+
// Relation envelope → its target document id.
|
|
244
|
+
if (isRelationEnvelope(value)) return value.targetDocumentId
|
|
245
|
+
// hasMany relation → comma-joined ids.
|
|
246
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isRelationEnvelope)) {
|
|
247
|
+
return value.map((v) => v.targetDocumentId).join(',')
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return JSON.stringify(value)
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isRelationEnvelope(value: unknown): value is { targetDocumentId: string } {
|
|
257
|
+
return (
|
|
258
|
+
typeof value === 'object' &&
|
|
259
|
+
value !== null &&
|
|
260
|
+
typeof (value as { targetDocumentId?: unknown }).targetDocumentId === 'string'
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 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).
|
|
272
|
+
*/
|
|
273
|
+
function findUploadFieldByPath(
|
|
274
|
+
fields: readonly Field[],
|
|
275
|
+
fieldPath: string
|
|
276
|
+
): UploadConfig | undefined {
|
|
277
|
+
const segments = fieldPath.split('.').map((s) => s.replace(/\[\d+\]$/, ''))
|
|
278
|
+
|
|
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
|
|
285
|
+
|
|
286
|
+
if (isLeaf) {
|
|
287
|
+
return field.type === 'image' || field.type === 'file' ? field.upload : undefined
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (field.type === 'group' || field.type === 'array') {
|
|
291
|
+
currentFields = field.fields
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
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
|
|
300
|
+
}
|
|
301
|
+
return undefined
|
|
302
|
+
}
|
|
303
|
+
return undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
110
306
|
/**
|
|
111
307
|
* Progress callback type for upload execution with progress tracking.
|
|
112
308
|
*/
|
|
@@ -124,7 +320,8 @@ export type UploadProgressCallback = (info: {
|
|
|
124
320
|
export async function executeUploadsWithProgress(
|
|
125
321
|
pendingUploads: Map<string, PendingUpload>,
|
|
126
322
|
uploadField: UploadFieldFn,
|
|
127
|
-
onProgress?: UploadProgressCallback
|
|
323
|
+
onProgress?: UploadProgressCallback,
|
|
324
|
+
executionContext?: UploadExecutionContext
|
|
128
325
|
): Promise<ExecuteUploadsResult> {
|
|
129
326
|
const results: UploadResult[] = []
|
|
130
327
|
const successful = new Map<string, StoredFileValue>()
|
|
@@ -145,9 +342,7 @@ export async function executeUploadsWithProgress(
|
|
|
145
342
|
status: 'uploading',
|
|
146
343
|
})
|
|
147
344
|
|
|
148
|
-
const formData =
|
|
149
|
-
formData.append('file', upload.file)
|
|
150
|
-
formData.append('field', uploadFieldName(fieldPath))
|
|
345
|
+
const formData = buildUploadFormData(fieldPath, upload, executionContext)
|
|
151
346
|
|
|
152
347
|
try {
|
|
153
348
|
const result = await uploadField(upload.collectionPath, formData, false)
|