@byline/admin 3.18.0 → 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.
@@ -25,7 +25,7 @@ const FileField = ({ field, collectionPath, value, defaultValue, onChange: _onCh
25
25
  const isDirty = useIsDirty(fieldPath);
26
26
  const fieldValue = useFieldValue(fieldPath);
27
27
  const isUploading = useIsFieldUploading(fieldPath);
28
- const { removePendingUpload } = useFormContext();
28
+ const { removePendingUpload, documentId } = useFormContext();
29
29
  const handleChange = useFieldChangeHandler(field, fieldPath);
30
30
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
31
31
  const isPending = isPendingStoredFileValue(incomingValue);
@@ -35,6 +35,7 @@ const FileField = ({ field, collectionPath, value, defaultValue, onChange: _onCh
35
35
  return 'placeholder' === maybe.storageProvider && 'pending' === maybe.storagePath;
36
36
  };
37
37
  const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
38
+ const uploadGated = field.upload?.requireSavedDocument === true && null == documentId;
38
39
  const handleRemove = ()=>{
39
40
  if (isPending) removePendingUpload(fieldPath);
40
41
  handleChange(null);
@@ -54,7 +55,12 @@ const FileField = ({ field, collectionPath, value, defaultValue, onChange: _onCh
54
55
  required: !field.optional
55
56
  })
56
57
  }),
57
- showUploadWidget ? collectionPath ? /*#__PURE__*/ jsx(FileUploadField, {
58
+ showUploadWidget ? uploadGated ? /*#__PURE__*/ jsx("div", {
59
+ className: classnames('byline-field-file-empty', file_field_module.empty),
60
+ role: "note",
61
+ "data-testid": "upload-require-saved-document",
62
+ children: t('fields.upload.requireSavedDocument')
63
+ }) : collectionPath ? /*#__PURE__*/ jsx(FileUploadField, {
58
64
  field: field,
59
65
  collectionPath: collectionPath,
60
66
  fieldPath: fieldPath,
@@ -14,7 +14,7 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
14
14
  const isDirty = useIsDirty(fieldPath);
15
15
  const fieldValue = useFieldValue(fieldPath);
16
16
  const isUploading = useIsFieldUploading(fieldPath);
17
- const { removePendingUpload } = useFormContext();
17
+ const { removePendingUpload, documentId } = useFormContext();
18
18
  const { t } = useTranslation('byline-admin');
19
19
  const handleChange = useFieldChangeHandler(field, fieldPath);
20
20
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
@@ -25,6 +25,7 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
25
25
  return 'placeholder' === maybe.storageProvider && 'pending' === maybe.storagePath;
26
26
  };
27
27
  const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
28
+ const uploadGated = field.upload?.requireSavedDocument === true && null == documentId;
28
29
  const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v)=>'thumbnail' === v.name) : void 0;
29
30
  const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
30
31
  const handleRemove = ()=>{
@@ -46,7 +47,12 @@ const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onC
46
47
  required: !field.optional
47
48
  })
48
49
  }),
49
- showUploadWidget ? collectionPath ? /*#__PURE__*/ jsx(ImageUploadField, {
50
+ showUploadWidget ? uploadGated ? /*#__PURE__*/ jsx("div", {
51
+ className: classnames('byline-field-image-empty', image_field_module.empty),
52
+ role: "note",
53
+ "data-testid": "upload-require-saved-document",
54
+ children: t('fields.upload.requireSavedDocument')
55
+ }) : collectionPath ? /*#__PURE__*/ jsx(ImageUploadField, {
50
56
  field: field,
51
57
  collectionPath: collectionPath,
52
58
  fieldPath: fieldPath,
@@ -48,6 +48,13 @@ export interface DirtyBreakdown {
48
48
  availableLocalesDirty: boolean;
49
49
  }
50
50
  interface FormContextType {
51
+ /**
52
+ * The persisted document id when the form edits an existing document,
53
+ * `null` while the document is unsaved (create mode). Upload widgets use
54
+ * this to honour `upload.requireSavedDocument` (see `UploadConfig` in
55
+ * `@byline/core`).
56
+ */
57
+ documentId: string | null;
51
58
  setFieldValue: (name: string, value: any) => void;
52
59
  setFieldStore: (name: string, value: any) => void;
53
60
  getFieldValue: (name: string) => any;
@@ -90,9 +97,14 @@ interface FormContextType {
90
97
  subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
91
98
  }
92
99
  export declare const useFormContext: () => FormContextType;
93
- export declare const FormProvider: ({ children, initialData, }: {
100
+ export declare const FormProvider: ({ children, initialData, documentId, }: {
94
101
  children: React.ReactNode;
95
102
  initialData?: Record<string, any>;
103
+ /**
104
+ * The persisted document id (edit mode); `null` while unsaved. Exposed on
105
+ * the context for upload widgets honouring `upload.requireSavedDocument`.
106
+ */
107
+ documentId?: string | null;
96
108
  }) => React.JSX.Element;
97
109
  /**
98
110
  * Subscribe to the system `path` slot edited by the path widget.
@@ -22,7 +22,7 @@ const useFormContext = ()=>{
22
22
  if (null == context) throw new Error('useFormContext must be used within a FormProvider');
23
23
  return context;
24
24
  };
25
- const FormProvider = ({ children, initialData = {} })=>{
25
+ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
26
26
  const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
27
27
  const initialValues = useRef(initialData?.fields ?? initialData);
28
28
  const errorsRef = useRef([]);
@@ -376,6 +376,7 @@ const FormProvider = ({ children, initialData = {} })=>{
376
376
  ]);
377
377
  return /*#__PURE__*/ jsx(FormContext.Provider, {
378
378
  value: {
379
+ documentId,
379
380
  setFieldValue,
380
381
  setFieldStore,
381
382
  getFieldValue,
@@ -122,6 +122,10 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
122
122
  try {
123
123
  const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status })=>{
124
124
  setFieldUploading(fieldPath, 'uploading' === status);
125
+ }, {
126
+ documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : void 0,
127
+ fields,
128
+ getFormValues: getFieldValues
125
129
  });
126
130
  if (!uploadResult.allSucceeded) {
127
131
  for (const [fieldPath, errorMessage] of uploadResult.errors.entries())setFieldError(fieldPath, t('forms.uploadFailedFieldError', {
@@ -398,6 +402,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
398
402
  const savedTabsRef = useRef({});
399
403
  return /*#__PURE__*/ jsx(FormProvider, {
400
404
  initialData: initialData,
405
+ documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
401
406
  children: /*#__PURE__*/ jsx(FormContent, {
402
407
  mode: mode,
403
408
  fields: fields,
@@ -12,7 +12,7 @@
12
12
  * This enables "deferred uploads" — files are selected/previewed immediately
13
13
  * but only uploaded when the user clicks Save.
14
14
  */
15
- import type { StoredFileValue } from '@byline/core';
15
+ import type { Field, StoredFileValue } from '@byline/core';
16
16
  import type { UploadFieldFn } from '../fields/field-services-types';
17
17
  import type { PendingUpload } from './form-context';
18
18
  export interface UploadResult {
@@ -31,6 +31,28 @@ export interface ExecuteUploadsResult {
31
31
  /** Whether all uploads succeeded */
32
32
  allSucceeded: boolean;
33
33
  }
34
+ /**
35
+ * Optional document context threaded from the form renderer so upload
36
+ * requests carry the state that server-side `beforeStore` / `afterStore`
37
+ * hooks need (see `UploadConfig.context` in `@byline/core`).
38
+ */
39
+ export interface UploadExecutionContext {
40
+ /**
41
+ * The persisted document id (edit mode). Posted as `documentId` on every
42
+ * upload request; omitted while the document is unsaved (create mode).
43
+ */
44
+ documentId?: string;
45
+ /**
46
+ * The collection's schema fields — used to locate each upload field's
47
+ * `upload.context` declaration by walking the pending upload's field path.
48
+ */
49
+ fields?: readonly Field[];
50
+ /**
51
+ * Snapshot accessor for the live form values, resolved lazily per upload
52
+ * so context reflects the state at the moment the request is built.
53
+ */
54
+ getFormValues?: () => Record<string, any>;
55
+ }
34
56
  /**
35
57
  * Execute all pending uploads sequentially.
36
58
  * Returns a result object with successful uploads and any errors.
@@ -38,9 +60,11 @@ export interface ExecuteUploadsResult {
38
60
  * @param pendingUploads - Map of field path to PendingUpload
39
61
  * @param uploadField - Host-provided upload transport (resolved via
40
62
  * `useBylineFieldServices()` in the calling React tree)
63
+ * @param executionContext - Optional document/form context appended to each
64
+ * upload request (documentId, `upload.context` values)
41
65
  * @returns Promise resolving to ExecuteUploadsResult
42
66
  */
43
- export declare function executeUploads(pendingUploads: Map<string, PendingUpload>, uploadField: UploadFieldFn): Promise<ExecuteUploadsResult>;
67
+ export declare function executeUploads(pendingUploads: Map<string, PendingUpload>, uploadField: UploadFieldFn, executionContext?: UploadExecutionContext): Promise<ExecuteUploadsResult>;
44
68
  /**
45
69
  * Progress callback type for upload execution with progress tracking.
46
70
  */
@@ -54,4 +78,4 @@ export type UploadProgressCallback = (info: {
54
78
  * Execute uploads with progress callbacks.
55
79
  * Useful for showing upload progress in the UI.
56
80
  */
57
- export declare function executeUploadsWithProgress(pendingUploads: Map<string, PendingUpload>, uploadField: UploadFieldFn, onProgress?: UploadProgressCallback): Promise<ExecuteUploadsResult>;
81
+ export declare function executeUploadsWithProgress(pendingUploads: Map<string, PendingUpload>, uploadField: UploadFieldFn, onProgress?: UploadProgressCallback, executionContext?: UploadExecutionContext): Promise<ExecuteUploadsResult>;
@@ -1,11 +1,10 @@
1
- async function executeUploads(pendingUploads, uploadField) {
1
+ import { get } from "./nested-path.js";
2
+ async function executeUploads(pendingUploads, uploadField, executionContext) {
2
3
  const results = [];
3
4
  const successful = new Map();
4
5
  const errors = new Map();
5
6
  for (const [fieldPath, upload] of pendingUploads.entries()){
6
- const formData = new FormData();
7
- formData.append('file', upload.file);
8
- formData.append('field', uploadFieldName(fieldPath));
7
+ const formData = buildUploadFormData(fieldPath, upload, executionContext);
9
8
  try {
10
9
  const result = await uploadField(upload.collectionPath, formData, false);
11
10
  results.push({
@@ -31,11 +30,89 @@ async function executeUploads(pendingUploads, uploadField) {
31
30
  allSucceeded: 0 === errors.size
32
31
  };
33
32
  }
33
+ function buildUploadFormData(fieldPath, upload, executionContext) {
34
+ const formData = new FormData();
35
+ formData.append('file', upload.file);
36
+ formData.append('field', uploadFieldName(fieldPath));
37
+ formData.append('fieldPath', fieldPath);
38
+ if (executionContext?.documentId) formData.append('documentId', executionContext.documentId);
39
+ const contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath)?.context : void 0;
40
+ if (contextPaths && contextPaths.length > 0 && executionContext?.getFormValues) {
41
+ const formValues = executionContext.getFormValues();
42
+ for (const contextPath of contextPaths){
43
+ const resolvedPath = resolveContextPath(fieldPath, contextPath);
44
+ if (void 0 === resolvedPath) continue;
45
+ const value = '' === resolvedPath ? formValues : get(formValues, resolvedPath);
46
+ const serialized = serializeContextValue(value);
47
+ if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
48
+ }
49
+ }
50
+ return formData;
51
+ }
34
52
  function uploadFieldName(fieldPath) {
35
53
  const dot = fieldPath.lastIndexOf('.');
36
54
  return -1 === dot ? fieldPath : fieldPath.slice(dot + 1);
37
55
  }
38
- async function executeUploadsWithProgress(pendingUploads, uploadField, onProgress) {
56
+ function leafName(contextPath) {
57
+ const segments = contextPath.split('/').pop() ?? contextPath;
58
+ const dot = segments.lastIndexOf('.');
59
+ return -1 === dot ? segments : segments.slice(dot + 1);
60
+ }
61
+ function resolveContextPath(fieldPath, contextPath) {
62
+ if (contextPath.startsWith('/')) return contextPath.slice(1);
63
+ const scope = fieldPath.split('.');
64
+ scope.pop();
65
+ const parts = contextPath.split('/');
66
+ const leaf = parts.pop() ?? '';
67
+ for (const part of parts)if ('.' !== part && '' !== part) {
68
+ if ('..' === part) {
69
+ if (0 === scope.length) return;
70
+ scope.pop();
71
+ continue;
72
+ }
73
+ scope.push(part);
74
+ }
75
+ return 0 === scope.length ? leaf : `${scope.join('.')}${leaf ? `.${leaf}` : ''}`;
76
+ }
77
+ function serializeContextValue(value) {
78
+ if (null == value) return;
79
+ if ('string' == typeof value) return value;
80
+ if ('number' == typeof value || 'boolean' == typeof value) return String(value);
81
+ if (isRelationEnvelope(value)) return value.targetDocumentId;
82
+ if (Array.isArray(value) && value.length > 0 && value.every(isRelationEnvelope)) return value.map((v)=>v.targetDocumentId).join(',');
83
+ try {
84
+ return JSON.stringify(value);
85
+ } catch {
86
+ return;
87
+ }
88
+ }
89
+ function isRelationEnvelope(value) {
90
+ return 'object' == typeof value && null !== value && 'string' == typeof value.targetDocumentId;
91
+ }
92
+ function findUploadFieldByPath(fields, fieldPath) {
93
+ const segments = fieldPath.split('.').map((s)=>s.replace(/\[\d+\]$/, ''));
94
+ let currentFields = fields;
95
+ for(let i = 0; i < segments.length; i++){
96
+ const name = segments[i];
97
+ const isLeaf = i === segments.length - 1;
98
+ const field = currentFields.find((f)=>f.name === name);
99
+ if (!field) break;
100
+ if (isLeaf) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
101
+ if ('group' === field.type || 'array' === field.type) {
102
+ currentFields = field.fields;
103
+ continue;
104
+ }
105
+ if ('blocks' === field.type) {
106
+ const remaining = segments[i + 1];
107
+ const block = field.blocks.find((b)=>b.fields.some((f)=>f.name === remaining));
108
+ if (!block) return;
109
+ currentFields = block.fields;
110
+ continue;
111
+ }
112
+ break;
113
+ }
114
+ }
115
+ async function executeUploadsWithProgress(pendingUploads, uploadField, onProgress, executionContext) {
39
116
  const results = [];
40
117
  const successful = new Map();
41
118
  const errors = new Map();
@@ -51,9 +128,7 @@ async function executeUploadsWithProgress(pendingUploads, uploadField, onProgres
51
128
  fieldPath,
52
129
  status: 'uploading'
53
130
  });
54
- const formData = new FormData();
55
- formData.append('file', upload.file);
56
- formData.append('field', uploadFieldName(fieldPath));
131
+ const formData = buildUploadFormData(fieldPath, upload, executionContext);
57
132
  try {
58
133
  const result = await uploadField(upload.collectionPath, formData, false);
59
134
  results.push({
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.18.0",
5
+ "version": "3.19.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -155,10 +155,10 @@
155
155
  "react-diff-viewer-continued": "^4.2.2",
156
156
  "uuid": "^14.0.1",
157
157
  "zod": "^4.4.3",
158
- "@byline/auth": "3.18.0",
159
- "@byline/i18n": "3.18.0",
160
- "@byline/ui": "3.18.0",
161
- "@byline/core": "3.18.0"
158
+ "@byline/auth": "3.19.0",
159
+ "@byline/ui": "3.19.0",
160
+ "@byline/core": "3.19.0",
161
+ "@byline/i18n": "3.19.0"
162
162
  },
163
163
  "peerDependencies": {
164
164
  "react": "^19.0.0",
@@ -79,7 +79,7 @@ export const FileField = ({
79
79
  const isDirty = useIsDirty(fieldPath)
80
80
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
81
81
  const isUploading = useIsFieldUploading(fieldPath)
82
- const { removePendingUpload } = useFormContext()
82
+ const { removePendingUpload, documentId } = useFormContext()
83
83
 
84
84
  const handleChange = useFieldChangeHandler(field, fieldPath)
85
85
 
@@ -102,6 +102,12 @@ export const FileField = ({
102
102
 
103
103
  const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
104
104
 
105
+ // `upload.requireSavedDocument` gate: until the document is persisted,
106
+ // render a "save first" notice in place of the upload zone. Server-side
107
+ // upload hooks that depend on save-time state (counters, document id)
108
+ // rely on this; existing stored values still render normally below.
109
+ const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
110
+
105
111
  const handleRemove = () => {
106
112
  if (isPending) {
107
113
  removePendingUpload(fieldPath)
@@ -131,7 +137,15 @@ export const FileField = ({
131
137
  </div>
132
138
 
133
139
  {showUploadWidget ? (
134
- collectionPath ? (
140
+ uploadGated ? (
141
+ <div
142
+ className={cx('byline-field-file-empty', styles.empty)}
143
+ role="note"
144
+ data-testid="upload-require-saved-document"
145
+ >
146
+ {t('fields.upload.requireSavedDocument')}
147
+ </div>
148
+ ) : collectionPath ? (
135
149
  <FileUploadField
136
150
  field={field}
137
151
  collectionPath={collectionPath}
@@ -61,7 +61,7 @@ export const ImageField = ({
61
61
  const isDirty = useIsDirty(fieldPath)
62
62
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
63
63
  const isUploading = useIsFieldUploading(fieldPath)
64
- const { removePendingUpload } = useFormContext()
64
+ const { removePendingUpload, documentId } = useFormContext()
65
65
  const { t } = useTranslation('byline-admin')
66
66
 
67
67
  // Re-use the standard field change handler so patches are emitted correctly.
@@ -87,6 +87,12 @@ export const ImageField = ({
87
87
  // Show upload widget only if no value or old placeholder
88
88
  const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
89
89
 
90
+ // `upload.requireSavedDocument` gate: until the document is persisted,
91
+ // render a "save first" notice in place of the upload zone. Server-side
92
+ // upload hooks that depend on save-time state (counters, document id)
93
+ // rely on this; existing stored values still render normally below.
94
+ const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
95
+
90
96
  // Prefer the generated thumbnail variant for the preview tile. SVGs and
91
97
  // other bypass types have no variants — fall back to the original.
92
98
  const thumbVariant =
@@ -122,7 +128,15 @@ export const ImageField = ({
122
128
  </div>
123
129
 
124
130
  {showUploadWidget ? (
125
- collectionPath ? (
131
+ uploadGated ? (
132
+ <div
133
+ className={cx('byline-field-image-empty', styles.empty)}
134
+ role="note"
135
+ data-testid="upload-require-saved-document"
136
+ >
137
+ {t('fields.upload.requireSavedDocument')}
138
+ </div>
139
+ ) : collectionPath ? (
126
140
  <ImageUploadField
127
141
  field={field}
128
142
  collectionPath={collectionPath}
@@ -83,6 +83,13 @@ const SYSTEM_PATH_DIRTY_KEY = '__systemPath__'
83
83
  const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__'
84
84
 
85
85
  interface FormContextType {
86
+ /**
87
+ * The persisted document id when the form edits an existing document,
88
+ * `null` while the document is unsaved (create mode). Upload widgets use
89
+ * this to honour `upload.requireSavedDocument` (see `UploadConfig` in
90
+ * `@byline/core`).
91
+ */
92
+ documentId: string | null
86
93
  setFieldValue: (name: string, value: any) => void
87
94
  setFieldStore: (name: string, value: any) => void
88
95
  getFieldValue: (name: string) => any
@@ -150,9 +157,15 @@ export const useFormContext = () => {
150
157
  export const FormProvider = ({
151
158
  children,
152
159
  initialData = {},
160
+ documentId = null,
153
161
  }: {
154
162
  children: React.ReactNode
155
163
  initialData?: Record<string, any>
164
+ /**
165
+ * The persisted document id (edit mode); `null` while unsaved. Exposed on
166
+ * the context for upload widgets honouring `upload.requireSavedDocument`.
167
+ */
168
+ documentId?: string | null
156
169
  }) => {
157
170
  const fieldValues = useRef<Record<string, any>>(
158
171
  JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
@@ -640,6 +653,7 @@ export const FormProvider = ({
640
653
  return (
641
654
  <FormContext.Provider
642
655
  value={{
656
+ documentId,
643
657
  setFieldValue,
644
658
  setFieldStore,
645
659
  getFieldValue,
@@ -387,6 +387,16 @@ const FormContent = ({
387
387
  uploadField,
388
388
  ({ fieldPath, status }) => {
389
389
  setFieldUploading(fieldPath, status === 'uploading')
390
+ },
391
+ {
392
+ // Document context for server-side upload hooks: the persisted
393
+ // document id (edit mode only) plus any `upload.context` form
394
+ // values declared on the schema field. See UploadConfig.context
395
+ // in @byline/core.
396
+ documentId:
397
+ mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
398
+ fields,
399
+ getFormValues: getFieldValues,
390
400
  }
391
401
  )
392
402
 
@@ -765,6 +775,7 @@ export const FormRenderer = ({
765
775
  <FormProvider
766
776
  key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
767
777
  initialData={initialData}
778
+ documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
768
779
  >
769
780
  <FormContent
770
781
  mode={mode}
@@ -0,0 +1,176 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { Field } from '@byline/core'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+
12
+ import { executeUploads } from './upload-executor'
13
+ import type { PendingUpload } from './form-context'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Schema mirroring the publications files-array shape. */
20
+ const publicationsFields: Field[] = [
21
+ { name: 'title', label: 'Title', type: 'text' },
22
+ { name: 'serialNumber', label: 'Serial', type: 'counter', group: 'test-serial' },
23
+ {
24
+ name: 'files',
25
+ label: 'Files',
26
+ type: 'array',
27
+ fields: [
28
+ {
29
+ name: 'filesGroup',
30
+ type: 'group',
31
+ fields: [
32
+ {
33
+ name: 'publicationFile',
34
+ label: 'File',
35
+ type: 'file',
36
+ upload: {
37
+ mimeTypes: ['application/pdf'],
38
+ context: ['language', '/serialNumber', '../missingSibling'],
39
+ },
40
+ },
41
+ {
42
+ name: 'language',
43
+ label: 'Language',
44
+ type: 'relation',
45
+ targetCollection: 'languages',
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ name: 'cover',
53
+ label: 'Cover',
54
+ type: 'image',
55
+ upload: { mimeTypes: ['image/*'] },
56
+ },
57
+ ]
58
+
59
+ function pendingUpload(name = 'test.pdf'): PendingUpload {
60
+ return {
61
+ file: new File(['%PDF'], name, { type: 'application/pdf' }),
62
+ previewUrl: 'blob:mock',
63
+ collectionPath: 'publications',
64
+ }
65
+ }
66
+
67
+ /** Capture the FormData each call receives; echo a minimal result. */
68
+ function captureUploadField() {
69
+ const bodies: FormData[] = []
70
+ const fn = vi.fn(async (_collection: string, formData: FormData) => {
71
+ bodies.push(formData)
72
+ return { storedFile: { fileId: '1', filename: 'stored.pdf' } as any }
73
+ })
74
+ return { fn, bodies }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tests
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('executeUploads — context transmission', () => {
82
+ it('always sends field (leaf name) and fieldPath; documentId only when provided', async () => {
83
+ const { fn, bodies } = captureUploadField()
84
+ const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]])
85
+
86
+ await executeUploads(uploads, fn, {
87
+ documentId: 'doc-123',
88
+ fields: publicationsFields,
89
+ getFormValues: () => ({}),
90
+ })
91
+
92
+ const body = bodies[0]!
93
+ expect(body.get('field')).toBe('publicationFile')
94
+ expect(body.get('fieldPath')).toBe('files[0].filesGroup.publicationFile')
95
+ expect(body.get('documentId')).toBe('doc-123')
96
+ })
97
+
98
+ it('omits documentId in create mode (undefined)', async () => {
99
+ const { fn, bodies } = captureUploadField()
100
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
101
+
102
+ await executeUploads(uploads, fn, { fields: publicationsFields, getFormValues: () => ({}) })
103
+
104
+ expect(bodies[0]?.get('documentId')).toBeNull()
105
+ })
106
+
107
+ it('resolves sibling, root-absolute, and missing context paths against the item scope', async () => {
108
+ const { fn, bodies } = captureUploadField()
109
+ const uploads = new Map([['files[1].filesGroup.publicationFile', pendingUpload()]])
110
+
111
+ await executeUploads(uploads, fn, {
112
+ documentId: 'doc-123',
113
+ fields: publicationsFields,
114
+ getFormValues: () => ({
115
+ serialNumber: 447,
116
+ files: [
117
+ { filesGroup: { language: { targetDocumentId: 'lang-th', targetCollectionId: 'c' } } },
118
+ { filesGroup: { language: { targetDocumentId: 'lang-en', targetCollectionId: 'c' } } },
119
+ ],
120
+ }),
121
+ })
122
+
123
+ const body = bodies[0]!
124
+ // Sibling path reads THIS item's language, not item 0's.
125
+ expect(body.get('language')).toBe('lang-en')
126
+ // Root-absolute path.
127
+ expect(body.get('serialNumber')).toBe('447')
128
+ // Unresolvable context values are omitted, not sent as ''.
129
+ expect(body.get('missingSibling')).toBeNull()
130
+ })
131
+
132
+ it('serialises relation envelopes to their targetDocumentId', async () => {
133
+ const { fn, bodies } = captureUploadField()
134
+ const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]])
135
+
136
+ await executeUploads(uploads, fn, {
137
+ fields: publicationsFields,
138
+ getFormValues: () => ({
139
+ serialNumber: 1,
140
+ files: [
141
+ { filesGroup: { language: { targetDocumentId: 'lang-de', targetCollectionId: 'c' } } },
142
+ ],
143
+ }),
144
+ })
145
+
146
+ expect(bodies[0]?.get('language')).toBe('lang-de')
147
+ })
148
+
149
+ it('fields without upload.context send no extra entries', async () => {
150
+ const { fn, bodies } = captureUploadField()
151
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
152
+
153
+ await executeUploads(uploads, fn, {
154
+ documentId: 'doc-9',
155
+ fields: publicationsFields,
156
+ getFormValues: () => ({ serialNumber: 5 }),
157
+ })
158
+
159
+ const body = bodies[0]!
160
+ expect(body.get('field')).toBe('cover')
161
+ expect(body.get('serialNumber')).toBeNull()
162
+ })
163
+
164
+ it('works without an execution context (legacy call shape)', async () => {
165
+ const { fn, bodies } = captureUploadField()
166
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
167
+
168
+ const result = await executeUploads(uploads, fn)
169
+
170
+ expect(result.allSucceeded).toBe(true)
171
+ const body = bodies[0]!
172
+ expect(body.get('field')).toBe('cover')
173
+ expect(body.get('fieldPath')).toBe('cover')
174
+ expect(body.get('documentId')).toBeNull()
175
+ })
176
+ })
@@ -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)