@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.
Files changed (31) hide show
  1. package/dist/fields/file/file-field.js +8 -2
  2. package/dist/fields/image/image-field.js +8 -2
  3. package/dist/forms/form-context.d.ts +13 -1
  4. package/dist/forms/form-context.js +2 -1
  5. package/dist/forms/form-renderer.js +18 -1
  6. package/dist/forms/path-widget.d.ts +19 -1
  7. package/dist/forms/path-widget.js +10 -6
  8. package/dist/forms/upload-executor.d.ts +27 -3
  9. package/dist/forms/upload-executor.js +83 -8
  10. package/dist/forms/upload-executor.test.node.d.ts +8 -0
  11. package/dist/modules/admin-account/errors.d.ts +2 -2
  12. package/dist/modules/admin-activity/abilities.d.ts +1 -1
  13. package/dist/modules/admin-permissions/abilities.d.ts +2 -2
  14. package/dist/modules/admin-permissions/errors.d.ts +2 -2
  15. package/dist/modules/admin-permissions/schemas.d.ts +4 -4
  16. package/dist/modules/admin-roles/abilities.d.ts +4 -4
  17. package/dist/modules/admin-roles/errors.d.ts +4 -4
  18. package/dist/modules/admin-users/abilities.d.ts +5 -5
  19. package/dist/modules/admin-users/errors.d.ts +5 -5
  20. package/dist/modules/admin-users/schemas.d.ts +6 -6
  21. package/dist/vendor/noble-argon2/blake2.js +17 -17
  22. package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +4 -18
  23. package/package.json +16 -16
  24. package/src/fields/file/file-field.tsx +16 -2
  25. package/src/fields/image/image-field.tsx +16 -2
  26. package/src/forms/form-context.tsx +14 -0
  27. package/src/forms/form-renderer.tsx +27 -0
  28. package/src/forms/path-widget.test.tsx +33 -0
  29. package/src/forms/path-widget.tsx +33 -5
  30. package/src/forms/upload-executor.test.node.ts +176 -0
  31. 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 = new 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 = new 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)