@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.
- 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 +5 -0
- 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/package.json +5 -5
- 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 +11 -0
- package/src/forms/upload-executor.test.node.ts +176 -0
- package/src/forms/upload-executor.ts +209 -14
|
@@ -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 ?
|
|
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 ?
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
159
|
-
"@byline/
|
|
160
|
-
"@byline/
|
|
161
|
-
"@byline/
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|