@byline/admin 4.3.0 → 4.4.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/array/array-field.d.ts +1 -8
- package/dist/fields/array/array-field.js +1 -3
- package/dist/fields/field-renderer.d.ts +1 -3
- package/dist/fields/field-renderer.js +3 -7
- package/dist/fields/file/file-field.d.ts +1 -3
- package/dist/fields/file/file-field.js +2 -2
- package/dist/fields/group/group-field.d.ts +1 -7
- package/dist/fields/group/group-field.js +1 -2
- package/dist/fields/image/image-field.d.ts +1 -3
- package/dist/fields/image/image-field.js +2 -2
- package/dist/forms/form-context.d.ts +20 -1
- package/dist/forms/form-context.js +2 -1
- package/dist/forms/form-renderer.js +1 -1
- package/dist/forms/upload-executor.js +50 -29
- package/package.json +5 -5
- package/src/fields/array/array-field.tsx +0 -10
- package/src/fields/field-renderer.tsx +0 -7
- package/src/fields/file/file-field.tsx +4 -4
- package/src/fields/group/group-field.tsx +0 -8
- package/src/fields/image/image-field.tsx +4 -4
- package/src/forms/form-context.tsx +21 -0
- package/src/forms/form-renderer.tsx +1 -1
- package/src/forms/upload-executor.test.node.ts +187 -0
- package/src/forms/upload-executor.ts +90 -29
|
@@ -6,18 +6,11 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { ArrayField as ArrayFieldType, FieldAdminConfig } from '@byline/core';
|
|
9
|
-
export declare const ArrayField: ({ field, defaultValue, path, disableSorting,
|
|
9
|
+
export declare const ArrayField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: {
|
|
10
10
|
field: ArrayFieldType;
|
|
11
11
|
defaultValue: any;
|
|
12
12
|
path: string;
|
|
13
13
|
disableSorting?: boolean;
|
|
14
|
-
/**
|
|
15
|
-
* Collection path forwarded to upload-capable fields (`file` / `image`)
|
|
16
|
-
* nested inside an array item, which need it to reach the `/upload`
|
|
17
|
-
* endpoint. Without it those fields fall back to their empty placeholder
|
|
18
|
-
* and never render an upload widget.
|
|
19
|
-
*/
|
|
20
|
-
collectionPath?: string;
|
|
21
14
|
/**
|
|
22
15
|
* Active content locale, forwarded to each array item's fields so
|
|
23
16
|
* localized widgets nested inside an array (e.g. a `localized` richText)
|
|
@@ -9,7 +9,7 @@ import { FieldRenderer } from "../field-renderer.js";
|
|
|
9
9
|
import { SortableItem, StaticItem } from "../sortable-item.js";
|
|
10
10
|
import { useFormContext } from "../../forms/form-context.js";
|
|
11
11
|
import array_field_module from "./array-field.module.js";
|
|
12
|
-
const ArrayField = ({ field, defaultValue, path, disableSorting = false,
|
|
12
|
+
const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin })=>{
|
|
13
13
|
const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext();
|
|
14
14
|
const { t } = useTranslation('byline-admin');
|
|
15
15
|
const [items, setItems] = useState([]);
|
|
@@ -127,7 +127,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
|
|
|
127
127
|
defaultValue: groupData[innerField.name],
|
|
128
128
|
basePath: `${arrayElementPath}.${childField.name}`,
|
|
129
129
|
disableSorting: true,
|
|
130
|
-
collectionPath: collectionPath,
|
|
131
130
|
contentLocale: contentLocale,
|
|
132
131
|
components: groupAdmin?.[innerField.name]?.components,
|
|
133
132
|
editor: groupAdmin?.[innerField.name]?.editor,
|
|
@@ -141,7 +140,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
|
|
|
141
140
|
defaultValue: initial,
|
|
142
141
|
basePath: arrayElementPath,
|
|
143
142
|
disableSorting: true,
|
|
144
|
-
collectionPath: collectionPath,
|
|
145
143
|
contentLocale: contentLocale,
|
|
146
144
|
components: fieldAdmin?.[childField.name]?.components,
|
|
147
145
|
editor: fieldAdmin?.[childField.name]?.editor,
|
|
@@ -12,8 +12,6 @@ interface FieldRendererProps {
|
|
|
12
12
|
basePath?: string;
|
|
13
13
|
disableSorting?: boolean;
|
|
14
14
|
hideLabel?: boolean;
|
|
15
|
-
/** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
|
|
16
|
-
collectionPath?: string;
|
|
17
15
|
/**
|
|
18
16
|
* The active content locale (e.g. `'en'`, `'fr'`). When provided and
|
|
19
17
|
* `field.localized === true`, a small locale badge is shown so the editor
|
|
@@ -42,5 +40,5 @@ interface FieldRendererProps {
|
|
|
42
40
|
*/
|
|
43
41
|
fieldAdmin?: Record<string, FieldAdminConfig>;
|
|
44
42
|
}
|
|
45
|
-
export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel,
|
|
43
|
+
export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
|
|
46
44
|
export {};
|
|
@@ -20,7 +20,7 @@ import { TextField } from "./text/text-field.js";
|
|
|
20
20
|
import { TextAreaField } from "./text-area/text-area-field.js";
|
|
21
21
|
import { useFieldChangeHandler } from "./use-field-change-handler.js";
|
|
22
22
|
import { useFieldCondition } from "./use-field-condition.js";
|
|
23
|
-
const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel,
|
|
23
|
+
const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin })=>{
|
|
24
24
|
const path = basePath ? `${basePath}.${field.name}` : field.name;
|
|
25
25
|
const htmlId = path.replace(/[[\].]/g, '-');
|
|
26
26
|
const handleChange = useFieldChangeHandler(field, path);
|
|
@@ -161,8 +161,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
|
|
|
161
161
|
} : field,
|
|
162
162
|
defaultValue: defaultValue,
|
|
163
163
|
onChange: handleChange,
|
|
164
|
-
path: path
|
|
165
|
-
collectionPath: collectionPath
|
|
164
|
+
path: path
|
|
166
165
|
});
|
|
167
166
|
case 'image':
|
|
168
167
|
return /*#__PURE__*/ jsx(ImageField, {
|
|
@@ -172,8 +171,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
|
|
|
172
171
|
} : field,
|
|
173
172
|
defaultValue: defaultValue,
|
|
174
173
|
onChange: handleChange,
|
|
175
|
-
path: path
|
|
176
|
-
collectionPath: collectionPath
|
|
174
|
+
path: path
|
|
177
175
|
});
|
|
178
176
|
case 'relation':
|
|
179
177
|
if (field.hasMany) return /*#__PURE__*/ jsx(RelationManyField, {
|
|
@@ -204,7 +202,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
|
|
|
204
202
|
defaultValue: defaultValue,
|
|
205
203
|
path: path,
|
|
206
204
|
disableSorting: disableSorting,
|
|
207
|
-
collectionPath: collectionPath,
|
|
208
205
|
contentLocale: contentLocale,
|
|
209
206
|
fieldAdmin: fieldAdmin
|
|
210
207
|
});
|
|
@@ -223,7 +220,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
|
|
|
223
220
|
defaultValue: defaultValue,
|
|
224
221
|
path: path,
|
|
225
222
|
disableSorting: disableSorting,
|
|
226
|
-
collectionPath: collectionPath,
|
|
227
223
|
contentLocale: contentLocale,
|
|
228
224
|
fieldAdmin: fieldAdmin
|
|
229
225
|
});
|
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
import { type FileField as FieldType, type StoredFileValue } from '@byline/core';
|
|
9
9
|
interface FileFieldProps {
|
|
10
10
|
field: FieldType;
|
|
11
|
-
/** Collection path required to call the /upload endpoint. */
|
|
12
|
-
collectionPath?: string;
|
|
13
11
|
value?: StoredFileValue | null;
|
|
14
12
|
defaultValue?: StoredFileValue | null;
|
|
15
13
|
onChange?: (value: StoredFileValue | null) => void;
|
|
16
14
|
path?: string;
|
|
17
15
|
}
|
|
18
|
-
export declare const FileField: ({ field,
|
|
16
|
+
export declare const FileField: ({ field, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => import("react").JSX.Element;
|
|
19
17
|
export {};
|
|
@@ -18,14 +18,14 @@ function triggerDownload(url, filename) {
|
|
|
18
18
|
a.click();
|
|
19
19
|
document.body.removeChild(a);
|
|
20
20
|
}
|
|
21
|
-
const FileField = ({ field,
|
|
21
|
+
const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
|
|
22
22
|
const fieldPath = path ?? field.name;
|
|
23
23
|
const { t } = useTranslation('byline-admin');
|
|
24
24
|
const fieldError = useFieldError(fieldPath);
|
|
25
25
|
const isDirty = useIsDirty(fieldPath);
|
|
26
26
|
const fieldValue = useFieldValue(fieldPath);
|
|
27
27
|
const isUploading = useIsFieldUploading(fieldPath);
|
|
28
|
-
const { removePendingUpload, documentId } = useFormContext();
|
|
28
|
+
const { removePendingUpload, documentId, collectionPath } = 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);
|
|
@@ -20,12 +20,6 @@ interface GroupFieldProps {
|
|
|
20
20
|
* listeners.
|
|
21
21
|
*/
|
|
22
22
|
disableSorting?: boolean;
|
|
23
|
-
/**
|
|
24
|
-
* Collection path forwarded to upload-capable child fields (`file` / `image`),
|
|
25
|
-
* which need it to reach the `/upload` endpoint. Without it those fields fall
|
|
26
|
-
* back to their empty placeholder and never render an upload widget.
|
|
27
|
-
*/
|
|
28
|
-
collectionPath?: string;
|
|
29
23
|
/**
|
|
30
24
|
* Active content locale, forwarded to child fields so localized widgets
|
|
31
25
|
* nested inside the group (e.g. a `localized` richText) can render their
|
|
@@ -44,5 +38,5 @@ interface GroupFieldProps {
|
|
|
44
38
|
*/
|
|
45
39
|
fieldAdmin?: Record<string, FieldAdminConfig>;
|
|
46
40
|
}
|
|
47
|
-
export declare const GroupField: ({ field, defaultValue, path, disableSorting,
|
|
41
|
+
export declare const GroupField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
|
|
48
42
|
export {};
|
|
@@ -7,7 +7,7 @@ import { placeholderForField } from "../field-helpers.js";
|
|
|
7
7
|
import { FieldRenderer } from "../field-renderer.js";
|
|
8
8
|
import { useFieldError } from "../../forms/form-context.js";
|
|
9
9
|
import group_field_module from "./group-field.module.js";
|
|
10
|
-
const GroupField = ({ field, defaultValue, path, disableSorting = true,
|
|
10
|
+
const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin })=>{
|
|
11
11
|
const fieldError = useFieldError(field.name);
|
|
12
12
|
const groupData = useMemo(()=>{
|
|
13
13
|
if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
|
|
@@ -48,7 +48,6 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, collecti
|
|
|
48
48
|
defaultValue: groupData[innerField.name],
|
|
49
49
|
basePath: path,
|
|
50
50
|
disableSorting: disableSorting,
|
|
51
|
-
collectionPath: collectionPath,
|
|
52
51
|
contentLocale: contentLocale,
|
|
53
52
|
components: fieldAdmin?.[innerField.name]?.components,
|
|
54
53
|
editor: fieldAdmin?.[innerField.name]?.editor,
|
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
import { type ImageField as FieldType, type StoredFileValue } from '@byline/core';
|
|
9
9
|
interface ImageFieldProps {
|
|
10
10
|
field: FieldType;
|
|
11
|
-
/** Collection path required to call the /upload endpoint. */
|
|
12
|
-
collectionPath?: string;
|
|
13
11
|
value?: StoredFileValue | null;
|
|
14
12
|
defaultValue?: StoredFileValue | null;
|
|
15
13
|
onChange?: (value: StoredFileValue | null) => void;
|
|
16
14
|
path?: string;
|
|
17
15
|
}
|
|
18
|
-
export declare const ImageField: ({ field,
|
|
16
|
+
export declare const ImageField: ({ field, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => import("react").JSX.Element;
|
|
19
17
|
export {};
|
|
@@ -8,13 +8,13 @@ import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUpl
|
|
|
8
8
|
import { useFieldChangeHandler } from "../use-field-change-handler.js";
|
|
9
9
|
import image_field_module from "./image-field.module.js";
|
|
10
10
|
import { ImageUploadField } from "./image-upload-field.js";
|
|
11
|
-
const ImageField = ({ field,
|
|
11
|
+
const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
|
|
12
12
|
const fieldPath = path ?? field.name;
|
|
13
13
|
const fieldError = useFieldError(fieldPath);
|
|
14
14
|
const isDirty = useIsDirty(fieldPath);
|
|
15
15
|
const fieldValue = useFieldValue(fieldPath);
|
|
16
16
|
const isUploading = useIsFieldUploading(fieldPath);
|
|
17
|
-
const { removePendingUpload, documentId } = useFormContext();
|
|
17
|
+
const { removePendingUpload, documentId, collectionPath } = 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;
|
|
@@ -55,6 +55,19 @@ interface FormContextType {
|
|
|
55
55
|
* `@byline/core`).
|
|
56
56
|
*/
|
|
57
57
|
documentId: string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Path of the collection this form edits, `null` when the form is rendered
|
|
60
|
+
* without one. Upload widgets need it to address the upload endpoint.
|
|
61
|
+
*
|
|
62
|
+
* It lives here rather than being passed down because it is constant for
|
|
63
|
+
* the whole form: threading it as a prop meant every nesting-capable
|
|
64
|
+
* container (`array`, `group`, `blocks`) had to remember to forward it,
|
|
65
|
+
* and a container that forgot silently rendered upload fields read-only —
|
|
66
|
+
* no error, just a missing drop zone. That happened twice, in `array` /
|
|
67
|
+
* `group` and then in `blocks`. A value read from context cannot be
|
|
68
|
+
* dropped by a container that never carries it.
|
|
69
|
+
*/
|
|
70
|
+
collectionPath: string | null;
|
|
58
71
|
setFieldValue: (name: string, value: any) => void;
|
|
59
72
|
setFieldStore: (name: string, value: any) => void;
|
|
60
73
|
getFieldValue: (name: string) => any;
|
|
@@ -97,7 +110,7 @@ interface FormContextType {
|
|
|
97
110
|
subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
|
|
98
111
|
}
|
|
99
112
|
export declare const useFormContext: () => FormContextType;
|
|
100
|
-
export declare const FormProvider: ({ children, initialData, documentId, }: {
|
|
113
|
+
export declare const FormProvider: ({ children, initialData, documentId, collectionPath, }: {
|
|
101
114
|
children: React.ReactNode;
|
|
102
115
|
initialData?: Record<string, any>;
|
|
103
116
|
/**
|
|
@@ -105,6 +118,12 @@ export declare const FormProvider: ({ children, initialData, documentId, }: {
|
|
|
105
118
|
* the context for upload widgets honouring `upload.requireSavedDocument`.
|
|
106
119
|
*/
|
|
107
120
|
documentId?: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* Path of the collection being edited. Exposed on the context so upload
|
|
123
|
+
* widgets can reach the upload endpoint from any nesting depth without
|
|
124
|
+
* every container forwarding it — see `FormContextType.collectionPath`.
|
|
125
|
+
*/
|
|
126
|
+
collectionPath?: string | null;
|
|
108
127
|
}) => React.JSX.Element;
|
|
109
128
|
/**
|
|
110
129
|
* 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 = {}, documentId = null })=>{
|
|
25
|
+
const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = 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([]);
|
|
@@ -377,6 +377,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
|
|
|
377
377
|
return /*#__PURE__*/ jsx(FormContext.Provider, {
|
|
378
378
|
value: {
|
|
379
379
|
documentId,
|
|
380
|
+
collectionPath,
|
|
380
381
|
setFieldValue,
|
|
381
382
|
setFieldStore,
|
|
382
383
|
getFieldValue,
|
|
@@ -183,7 +183,6 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
183
183
|
return /*#__PURE__*/ jsx(FieldRenderer, {
|
|
184
184
|
field: field,
|
|
185
185
|
defaultValue: initialData?.fields?.[field.name],
|
|
186
|
-
collectionPath: collectionPath,
|
|
187
186
|
contentLocale: contentLocale,
|
|
188
187
|
components: adminConfig?.fields?.[field.name]?.components,
|
|
189
188
|
editor: adminConfig?.fields?.[field.name]?.editor,
|
|
@@ -405,6 +404,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
|
|
|
405
404
|
return /*#__PURE__*/ jsx(FormProvider, {
|
|
406
405
|
initialData: initialData,
|
|
407
406
|
documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
|
|
407
|
+
collectionPath: collectionPath ?? null,
|
|
408
408
|
children: /*#__PURE__*/ jsx(FormContent, {
|
|
409
409
|
mode: mode,
|
|
410
410
|
fields: fields,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseInstancePath } from "@byline/core";
|
|
1
2
|
import { get } from "./nested-path.js";
|
|
2
3
|
async function executeUploads(pendingUploads, uploadField, executionContext) {
|
|
3
4
|
const results = [];
|
|
@@ -36,16 +37,14 @@ function buildUploadFormData(fieldPath, upload, executionContext) {
|
|
|
36
37
|
formData.append('field', uploadFieldName(fieldPath));
|
|
37
38
|
formData.append('fieldPath', fieldPath);
|
|
38
39
|
if (executionContext?.documentId) formData.append('documentId', executionContext.documentId);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
|
|
48
|
-
}
|
|
40
|
+
const formValues = executionContext?.getFormValues?.();
|
|
41
|
+
const contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath, formValues)?.context : void 0;
|
|
42
|
+
if (contextPaths && contextPaths.length > 0 && formValues) 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);
|
|
49
48
|
}
|
|
50
49
|
return formData;
|
|
51
50
|
}
|
|
@@ -89,27 +88,49 @@ function serializeContextValue(value) {
|
|
|
89
88
|
function isRelationEnvelope(value) {
|
|
90
89
|
return 'object' == typeof value && null !== value && 'string' == typeof value.targetDocumentId;
|
|
91
90
|
}
|
|
92
|
-
function findUploadFieldByPath(fields, fieldPath) {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
function findUploadFieldByPath(fields, fieldPath, formValues) {
|
|
92
|
+
const parsed = parseInstancePath(fieldPath);
|
|
93
|
+
if (!parsed.ok) return;
|
|
94
|
+
return resolveUploadConfig(fields, parsed.segments, formValues);
|
|
95
|
+
}
|
|
96
|
+
function selectItem(value, segment) {
|
|
97
|
+
if (!Array.isArray(value)) return;
|
|
98
|
+
if ('index' === segment.kind) return value[segment.index];
|
|
99
|
+
if ('id' === segment.kind) return value.find((item)=>item?._id === segment.id);
|
|
100
|
+
}
|
|
101
|
+
function resolveUploadConfig(fields, segments, value) {
|
|
102
|
+
const head = segments[0];
|
|
103
|
+
if (null == head || 'field' !== head.kind) return;
|
|
104
|
+
const field = fields.find((candidate)=>candidate.name === head.name);
|
|
105
|
+
if (null == field) return;
|
|
106
|
+
const fieldValue = value?.[head.name];
|
|
107
|
+
const rest = segments.slice(1);
|
|
108
|
+
if (0 === rest.length) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
|
|
109
|
+
if ('group' === field.type) return resolveUploadConfig(field.fields, rest, fieldValue);
|
|
110
|
+
if ('array' === field.type) {
|
|
111
|
+
const selector = rest[0];
|
|
112
|
+
if (selector?.kind === 'index' || selector?.kind === 'id') return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector));
|
|
113
|
+
return resolveUploadConfig(field.fields, rest, void 0);
|
|
114
|
+
}
|
|
115
|
+
if ('blocks' === field.type) {
|
|
116
|
+
const selector = rest[0];
|
|
117
|
+
const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest;
|
|
118
|
+
const item = selector?.kind === 'index' || selector?.kind === 'id' ? selectItem(fieldValue, selector) : void 0;
|
|
119
|
+
const blockType = item?._type;
|
|
120
|
+
if ('string' == typeof blockType) {
|
|
121
|
+
const block = field.blocks.find((candidate)=>candidate.blockType === blockType);
|
|
122
|
+
return null == block ? void 0 : resolveUploadConfig(block.fields, remainder, item);
|
|
104
123
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
let found;
|
|
125
|
+
let matches = 0;
|
|
126
|
+
for (const block of field.blocks){
|
|
127
|
+
const result = resolveUploadConfig(block.fields, remainder, void 0);
|
|
128
|
+
if (void 0 !== result) {
|
|
129
|
+
matches += 1;
|
|
130
|
+
found = result;
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
|
-
|
|
133
|
+
return 1 === matches ? found : void 0;
|
|
113
134
|
}
|
|
114
135
|
}
|
|
115
136
|
async function executeUploadsWithProgress(pendingUploads, uploadField, onProgress, executionContext) {
|
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": "4.
|
|
5
|
+
"version": "4.4.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -168,10 +168,10 @@
|
|
|
168
168
|
"react-diff-viewer-continued": "^4.4.0",
|
|
169
169
|
"uuid": "^14.0.1",
|
|
170
170
|
"zod": "^4.4.3",
|
|
171
|
-
"@byline/
|
|
172
|
-
"@byline/
|
|
173
|
-
"@byline/
|
|
174
|
-
"@byline/
|
|
171
|
+
"@byline/auth": "4.4.0",
|
|
172
|
+
"@byline/i18n": "4.4.0",
|
|
173
|
+
"@byline/ui": "4.4.0",
|
|
174
|
+
"@byline/core": "4.4.0"
|
|
175
175
|
},
|
|
176
176
|
"peerDependencies": {
|
|
177
177
|
"react": "^19.0.0",
|
|
@@ -31,7 +31,6 @@ export const ArrayField = ({
|
|
|
31
31
|
defaultValue,
|
|
32
32
|
path,
|
|
33
33
|
disableSorting = false,
|
|
34
|
-
collectionPath,
|
|
35
34
|
contentLocale,
|
|
36
35
|
fieldAdmin,
|
|
37
36
|
}: {
|
|
@@ -39,13 +38,6 @@ export const ArrayField = ({
|
|
|
39
38
|
defaultValue: any
|
|
40
39
|
path: string
|
|
41
40
|
disableSorting?: boolean
|
|
42
|
-
/**
|
|
43
|
-
* Collection path forwarded to upload-capable fields (`file` / `image`)
|
|
44
|
-
* nested inside an array item, which need it to reach the `/upload`
|
|
45
|
-
* endpoint. Without it those fields fall back to their empty placeholder
|
|
46
|
-
* and never render an upload widget.
|
|
47
|
-
*/
|
|
48
|
-
collectionPath?: string
|
|
49
41
|
/**
|
|
50
42
|
* Active content locale, forwarded to each array item's fields so
|
|
51
43
|
* localized widgets nested inside an array (e.g. a `localized` richText)
|
|
@@ -242,7 +234,6 @@ export const ArrayField = ({
|
|
|
242
234
|
defaultValue={groupData[innerField.name]}
|
|
243
235
|
basePath={`${arrayElementPath}.${childField.name}`}
|
|
244
236
|
disableSorting={true}
|
|
245
|
-
collectionPath={collectionPath}
|
|
246
237
|
contentLocale={contentLocale}
|
|
247
238
|
components={groupAdmin?.[innerField.name]?.components}
|
|
248
239
|
editor={groupAdmin?.[innerField.name]?.editor}
|
|
@@ -260,7 +251,6 @@ export const ArrayField = ({
|
|
|
260
251
|
defaultValue={initial}
|
|
261
252
|
basePath={arrayElementPath}
|
|
262
253
|
disableSorting={true}
|
|
263
|
-
collectionPath={collectionPath}
|
|
264
254
|
contentLocale={contentLocale}
|
|
265
255
|
components={fieldAdmin?.[childField.name]?.components}
|
|
266
256
|
editor={fieldAdmin?.[childField.name]?.editor}
|
|
@@ -49,8 +49,6 @@ interface FieldRendererProps {
|
|
|
49
49
|
basePath?: string
|
|
50
50
|
disableSorting?: boolean
|
|
51
51
|
hideLabel?: boolean
|
|
52
|
-
/** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
|
|
53
|
-
collectionPath?: string
|
|
54
52
|
/**
|
|
55
53
|
* The active content locale (e.g. `'en'`, `'fr'`). When provided and
|
|
56
54
|
* `field.localized === true`, a small locale badge is shown so the editor
|
|
@@ -86,7 +84,6 @@ export const FieldRenderer = ({
|
|
|
86
84
|
basePath,
|
|
87
85
|
disableSorting,
|
|
88
86
|
hideLabel,
|
|
89
|
-
collectionPath,
|
|
90
87
|
contentLocale,
|
|
91
88
|
components,
|
|
92
89
|
editor,
|
|
@@ -256,7 +253,6 @@ export const FieldRenderer = ({
|
|
|
256
253
|
defaultValue={defaultValue}
|
|
257
254
|
onChange={handleChange}
|
|
258
255
|
path={path}
|
|
259
|
-
collectionPath={collectionPath}
|
|
260
256
|
/>
|
|
261
257
|
)
|
|
262
258
|
case 'image':
|
|
@@ -266,7 +262,6 @@ export const FieldRenderer = ({
|
|
|
266
262
|
defaultValue={defaultValue}
|
|
267
263
|
onChange={handleChange}
|
|
268
264
|
path={path}
|
|
269
|
-
collectionPath={collectionPath}
|
|
270
265
|
/>
|
|
271
266
|
)
|
|
272
267
|
case 'relation':
|
|
@@ -301,7 +296,6 @@ export const FieldRenderer = ({
|
|
|
301
296
|
defaultValue={defaultValue}
|
|
302
297
|
path={path}
|
|
303
298
|
disableSorting={disableSorting}
|
|
304
|
-
collectionPath={collectionPath}
|
|
305
299
|
contentLocale={contentLocale}
|
|
306
300
|
fieldAdmin={fieldAdmin}
|
|
307
301
|
/>
|
|
@@ -324,7 +318,6 @@ export const FieldRenderer = ({
|
|
|
324
318
|
defaultValue={defaultValue}
|
|
325
319
|
path={path}
|
|
326
320
|
disableSorting={disableSorting}
|
|
327
|
-
collectionPath={collectionPath}
|
|
328
321
|
contentLocale={contentLocale}
|
|
329
322
|
fieldAdmin={fieldAdmin}
|
|
330
323
|
/>
|
|
@@ -57,8 +57,6 @@ function triggerDownload(url: string, filename?: string) {
|
|
|
57
57
|
|
|
58
58
|
interface FileFieldProps {
|
|
59
59
|
field: FieldType
|
|
60
|
-
/** Collection path required to call the /upload endpoint. */
|
|
61
|
-
collectionPath?: string
|
|
62
60
|
value?: StoredFileValue | null
|
|
63
61
|
defaultValue?: StoredFileValue | null
|
|
64
62
|
onChange?: (value: StoredFileValue | null) => void
|
|
@@ -67,7 +65,6 @@ interface FileFieldProps {
|
|
|
67
65
|
|
|
68
66
|
export const FileField = ({
|
|
69
67
|
field,
|
|
70
|
-
collectionPath,
|
|
71
68
|
value,
|
|
72
69
|
defaultValue,
|
|
73
70
|
onChange: _onChange,
|
|
@@ -79,7 +76,10 @@ export const FileField = ({
|
|
|
79
76
|
const isDirty = useIsDirty(fieldPath)
|
|
80
77
|
const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
|
|
81
78
|
const isUploading = useIsFieldUploading(fieldPath)
|
|
82
|
-
|
|
79
|
+
// `collectionPath` comes from form context rather than a prop: it is
|
|
80
|
+
// constant for the form, and prop-drilling it meant any container that
|
|
81
|
+
// forgot to forward it silently rendered this widget read-only.
|
|
82
|
+
const { removePendingUpload, documentId, collectionPath } = useFormContext()
|
|
83
83
|
|
|
84
84
|
const handleChange = useFieldChangeHandler(field, fieldPath)
|
|
85
85
|
|
|
@@ -43,12 +43,6 @@ interface GroupFieldProps {
|
|
|
43
43
|
* listeners.
|
|
44
44
|
*/
|
|
45
45
|
disableSorting?: boolean
|
|
46
|
-
/**
|
|
47
|
-
* Collection path forwarded to upload-capable child fields (`file` / `image`),
|
|
48
|
-
* which need it to reach the `/upload` endpoint. Without it those fields fall
|
|
49
|
-
* back to their empty placeholder and never render an upload widget.
|
|
50
|
-
*/
|
|
51
|
-
collectionPath?: string
|
|
52
46
|
/**
|
|
53
47
|
* Active content locale, forwarded to child fields so localized widgets
|
|
54
48
|
* nested inside the group (e.g. a `localized` richText) can render their
|
|
@@ -73,7 +67,6 @@ export const GroupField = ({
|
|
|
73
67
|
defaultValue,
|
|
74
68
|
path,
|
|
75
69
|
disableSorting = true,
|
|
76
|
-
collectionPath,
|
|
77
70
|
contentLocale,
|
|
78
71
|
fieldAdmin,
|
|
79
72
|
}: GroupFieldProps) => {
|
|
@@ -116,7 +109,6 @@ export const GroupField = ({
|
|
|
116
109
|
defaultValue={groupData[innerField.name]}
|
|
117
110
|
basePath={path}
|
|
118
111
|
disableSorting={disableSorting}
|
|
119
|
-
collectionPath={collectionPath}
|
|
120
112
|
contentLocale={contentLocale}
|
|
121
113
|
components={fieldAdmin?.[innerField.name]?.components}
|
|
122
114
|
editor={fieldAdmin?.[innerField.name]?.editor}
|
|
@@ -38,8 +38,6 @@ import { ImageUploadField } from './image-upload-field'
|
|
|
38
38
|
|
|
39
39
|
interface ImageFieldProps {
|
|
40
40
|
field: FieldType
|
|
41
|
-
/** Collection path required to call the /upload endpoint. */
|
|
42
|
-
collectionPath?: string
|
|
43
41
|
// Stored value is currently a plain object with file/image metadata
|
|
44
42
|
// coming from the seed data / storage layer.
|
|
45
43
|
value?: StoredFileValue | null
|
|
@@ -50,7 +48,6 @@ interface ImageFieldProps {
|
|
|
50
48
|
|
|
51
49
|
export const ImageField = ({
|
|
52
50
|
field,
|
|
53
|
-
collectionPath,
|
|
54
51
|
value,
|
|
55
52
|
defaultValue,
|
|
56
53
|
onChange: _onChange,
|
|
@@ -61,7 +58,10 @@ export const ImageField = ({
|
|
|
61
58
|
const isDirty = useIsDirty(fieldPath)
|
|
62
59
|
const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
|
|
63
60
|
const isUploading = useIsFieldUploading(fieldPath)
|
|
64
|
-
|
|
61
|
+
// `collectionPath` comes from form context rather than a prop: it is
|
|
62
|
+
// constant for the form, and prop-drilling it meant any container that
|
|
63
|
+
// forgot to forward it silently rendered this widget read-only.
|
|
64
|
+
const { removePendingUpload, documentId, collectionPath } = useFormContext()
|
|
65
65
|
const { t } = useTranslation('byline-admin')
|
|
66
66
|
|
|
67
67
|
// Re-use the standard field change handler so patches are emitted correctly.
|
|
@@ -90,6 +90,19 @@ interface FormContextType {
|
|
|
90
90
|
* `@byline/core`).
|
|
91
91
|
*/
|
|
92
92
|
documentId: string | null
|
|
93
|
+
/**
|
|
94
|
+
* Path of the collection this form edits, `null` when the form is rendered
|
|
95
|
+
* without one. Upload widgets need it to address the upload endpoint.
|
|
96
|
+
*
|
|
97
|
+
* It lives here rather than being passed down because it is constant for
|
|
98
|
+
* the whole form: threading it as a prop meant every nesting-capable
|
|
99
|
+
* container (`array`, `group`, `blocks`) had to remember to forward it,
|
|
100
|
+
* and a container that forgot silently rendered upload fields read-only —
|
|
101
|
+
* no error, just a missing drop zone. That happened twice, in `array` /
|
|
102
|
+
* `group` and then in `blocks`. A value read from context cannot be
|
|
103
|
+
* dropped by a container that never carries it.
|
|
104
|
+
*/
|
|
105
|
+
collectionPath: string | null
|
|
93
106
|
setFieldValue: (name: string, value: any) => void
|
|
94
107
|
setFieldStore: (name: string, value: any) => void
|
|
95
108
|
getFieldValue: (name: string) => any
|
|
@@ -158,6 +171,7 @@ export const FormProvider = ({
|
|
|
158
171
|
children,
|
|
159
172
|
initialData = {},
|
|
160
173
|
documentId = null,
|
|
174
|
+
collectionPath = null,
|
|
161
175
|
}: {
|
|
162
176
|
children: React.ReactNode
|
|
163
177
|
initialData?: Record<string, any>
|
|
@@ -166,6 +180,12 @@ export const FormProvider = ({
|
|
|
166
180
|
* the context for upload widgets honouring `upload.requireSavedDocument`.
|
|
167
181
|
*/
|
|
168
182
|
documentId?: string | null
|
|
183
|
+
/**
|
|
184
|
+
* Path of the collection being edited. Exposed on the context so upload
|
|
185
|
+
* widgets can reach the upload endpoint from any nesting depth without
|
|
186
|
+
* every container forwarding it — see `FormContextType.collectionPath`.
|
|
187
|
+
*/
|
|
188
|
+
collectionPath?: string | null
|
|
169
189
|
}) => {
|
|
170
190
|
const fieldValues = useRef<Record<string, any>>(
|
|
171
191
|
JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
|
|
@@ -662,6 +682,7 @@ export const FormProvider = ({
|
|
|
662
682
|
<FormContext.Provider
|
|
663
683
|
value={{
|
|
664
684
|
documentId,
|
|
685
|
+
collectionPath,
|
|
665
686
|
setFieldValue,
|
|
666
687
|
setFieldStore,
|
|
667
688
|
getFieldValue,
|
|
@@ -485,7 +485,6 @@ const FormContent = ({
|
|
|
485
485
|
key={field.name}
|
|
486
486
|
field={field}
|
|
487
487
|
defaultValue={initialData?.fields?.[field.name]}
|
|
488
|
-
collectionPath={collectionPath}
|
|
489
488
|
contentLocale={contentLocale}
|
|
490
489
|
components={adminConfig?.fields?.[field.name]?.components}
|
|
491
490
|
editor={adminConfig?.fields?.[field.name]?.editor}
|
|
@@ -778,6 +777,7 @@ export const FormRenderer = ({
|
|
|
778
777
|
key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
|
|
779
778
|
initialData={initialData}
|
|
780
779
|
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
780
|
+
collectionPath={collectionPath ?? null}
|
|
781
781
|
>
|
|
782
782
|
<FormContent
|
|
783
783
|
mode={mode}
|
|
@@ -56,6 +56,76 @@ const publicationsFields: Field[] = [
|
|
|
56
56
|
},
|
|
57
57
|
]
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Two block types that both declare an array named `gallery`, each holding a
|
|
61
|
+
* different upload-capable leaf. Leaf names stay unique across the
|
|
62
|
+
* collection, so this passes the boot-time uniqueness constraint — the shape
|
|
63
|
+
* is legal, and it is the shape that made block resolution ambiguous.
|
|
64
|
+
*/
|
|
65
|
+
const blocksFields: Field[] = [
|
|
66
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
67
|
+
{
|
|
68
|
+
name: 'content',
|
|
69
|
+
label: 'Content',
|
|
70
|
+
type: 'blocks',
|
|
71
|
+
blocks: [
|
|
72
|
+
{
|
|
73
|
+
blockType: 'photoBlock',
|
|
74
|
+
fields: [
|
|
75
|
+
{
|
|
76
|
+
name: 'gallery',
|
|
77
|
+
label: 'Gallery',
|
|
78
|
+
type: 'array',
|
|
79
|
+
fields: [
|
|
80
|
+
{
|
|
81
|
+
name: 'heroImage',
|
|
82
|
+
label: 'Hero',
|
|
83
|
+
type: 'image',
|
|
84
|
+
upload: { context: ['/title'] },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
blockType: 'videoBlock',
|
|
92
|
+
fields: [
|
|
93
|
+
{
|
|
94
|
+
name: 'gallery',
|
|
95
|
+
label: 'Gallery',
|
|
96
|
+
type: 'array',
|
|
97
|
+
fields: [
|
|
98
|
+
{
|
|
99
|
+
name: 'poster',
|
|
100
|
+
label: 'Poster',
|
|
101
|
+
type: 'image',
|
|
102
|
+
upload: { context: ['/title'] },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
/** Form state for `blocksFields`: a photoBlock then a videoBlock. */
|
|
113
|
+
const blocksFormValues = () => ({
|
|
114
|
+
title: 'Hello',
|
|
115
|
+
content: [
|
|
116
|
+
{ _type: 'photoBlock', _id: 'blk-photo', gallery: [{}] },
|
|
117
|
+
{ _type: 'videoBlock', _id: 'blk-video', gallery: [{}] },
|
|
118
|
+
],
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function imageUpload(name = 'p.png'): PendingUpload {
|
|
122
|
+
return {
|
|
123
|
+
file: new File(['x'], name, { type: 'image/png' }),
|
|
124
|
+
previewUrl: 'blob:mock',
|
|
125
|
+
collectionPath: 'pages',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
59
129
|
function pendingUpload(name = 'test.pdf'): PendingUpload {
|
|
60
130
|
return {
|
|
61
131
|
file: new File(['%PDF'], name, { type: 'application/pdf' }),
|
|
@@ -174,3 +244,120 @@ describe('executeUploads — context transmission', () => {
|
|
|
174
244
|
expect(body.get('documentId')).toBeNull()
|
|
175
245
|
})
|
|
176
246
|
})
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Block resolution
|
|
250
|
+
//
|
|
251
|
+
// A form field path is an INSTANCE path — `content[1].gallery[0].poster` — and
|
|
252
|
+
// carries no block type, because a block item holds its own `_type`. Resolving
|
|
253
|
+
// an upload field inside a block therefore means reading that item.
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe('executeUploads — upload fields inside blocks', () => {
|
|
257
|
+
it('resolves upload.context for a field in the first block', async () => {
|
|
258
|
+
const { fn, bodies } = captureUploadField()
|
|
259
|
+
const uploads = new Map([['content[0].gallery[0].heroImage', imageUpload('h.png')]])
|
|
260
|
+
|
|
261
|
+
await executeUploads(uploads, fn, {
|
|
262
|
+
fields: blocksFields,
|
|
263
|
+
getFormValues: blocksFormValues,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expect(bodies[0]?.get('title')).toBe('Hello')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('resolves upload.context for a field in a later block', async () => {
|
|
270
|
+
// Regression: block resolution used to match the first block declaring a
|
|
271
|
+
// field named by the next path segment. Both blocks declare `gallery`, so
|
|
272
|
+
// `photoBlock` won, `poster` was not found in it, and the declared context
|
|
273
|
+
// was dropped silently — no error, just a missing value the server-side
|
|
274
|
+
// beforeStore / afterStore hooks were relying on.
|
|
275
|
+
const { fn, bodies } = captureUploadField()
|
|
276
|
+
const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
|
|
277
|
+
|
|
278
|
+
await executeUploads(uploads, fn, {
|
|
279
|
+
fields: blocksFields,
|
|
280
|
+
getFormValues: blocksFormValues,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect(bodies[0]?.get('title')).toBe('Hello')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('addresses a block item by stable id as well as position', async () => {
|
|
287
|
+
const { fn, bodies } = captureUploadField()
|
|
288
|
+
const uploads = new Map([['content[id=blk-video].gallery[0].poster', imageUpload()]])
|
|
289
|
+
|
|
290
|
+
await executeUploads(uploads, fn, {
|
|
291
|
+
fields: blocksFields,
|
|
292
|
+
getFormValues: blocksFormValues,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
expect(bodies[0]?.get('title')).toBe('Hello')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('still resolves when the addressed block item is missing from form state', async () => {
|
|
299
|
+
// Form state can lag a pending upload. The item is what supplies `_type`,
|
|
300
|
+
// so its absence drops us to the unique-match fallback — but the *field*
|
|
301
|
+
// is a declaration, and `poster` is declared in exactly one block, so the
|
|
302
|
+
// answer is unchanged. Staleness costs nothing here; genuine ambiguity
|
|
303
|
+
// would still return nothing (see the ambiguous case below).
|
|
304
|
+
const { fn, bodies } = captureUploadField()
|
|
305
|
+
const uploads = new Map([['content[7].gallery[0].poster', imageUpload()]])
|
|
306
|
+
|
|
307
|
+
await executeUploads(uploads, fn, {
|
|
308
|
+
fields: blocksFields,
|
|
309
|
+
getFormValues: blocksFormValues,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
expect(bodies[0]?.get('title')).toBe('Hello')
|
|
313
|
+
expect(bodies[0]?.get('fieldPath')).toBe('content[7].gallery[0].poster')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('falls back to a unique match when form values cannot disambiguate', async () => {
|
|
317
|
+
// Without data the block type is unknowable from the path. `poster` is
|
|
318
|
+
// declared in exactly one block, so the answer is still unambiguous.
|
|
319
|
+
const { fn, bodies } = captureUploadField()
|
|
320
|
+
const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]])
|
|
321
|
+
|
|
322
|
+
await executeUploads(uploads, fn, {
|
|
323
|
+
fields: blocksFields,
|
|
324
|
+
getFormValues: () => ({ title: 'Hello' }),
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
expect(bodies[0]?.get('title')).toBe('Hello')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('sends no context when the path genuinely identifies no single declaration', async () => {
|
|
331
|
+
// Same leaf name in both blocks and no data to choose between them. A
|
|
332
|
+
// guess would be wrong half the time, so nothing is sent.
|
|
333
|
+
const ambiguous: Field[] = [
|
|
334
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
335
|
+
{
|
|
336
|
+
name: 'content',
|
|
337
|
+
label: 'Content',
|
|
338
|
+
type: 'blocks',
|
|
339
|
+
blocks: [
|
|
340
|
+
{
|
|
341
|
+
blockType: 'aBlock',
|
|
342
|
+
fields: [
|
|
343
|
+
{ name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
blockType: 'bBlock',
|
|
348
|
+
fields: [
|
|
349
|
+
{ name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
const { fn, bodies } = captureUploadField()
|
|
357
|
+
const uploads = new Map([['content[1].shared', imageUpload()]])
|
|
358
|
+
|
|
359
|
+
await executeUploads(uploads, fn, { fields: ambiguous, getFormValues: () => ({ title: 'x' }) })
|
|
360
|
+
|
|
361
|
+
expect(bodies[0]?.get('title')).toBeNull()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Field, StoredFileValue, UploadConfig } from '@byline/core'
|
|
18
|
+
import { type PathSegment, parseInstancePath } from '@byline/core'
|
|
18
19
|
|
|
19
20
|
import { get as getNestedValue } from './nested-path'
|
|
20
21
|
import type { UploadFieldFn } from '../fields/field-services-types'
|
|
@@ -149,13 +150,16 @@ function buildUploadFormData(
|
|
|
149
150
|
formData.append('documentId', executionContext.documentId)
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
// Resolved once per upload, before locating the field: a `blocks` hop in
|
|
154
|
+
// the path can only be resolved by reading the addressed item's `_type`.
|
|
155
|
+
const formValues = executionContext?.getFormValues?.()
|
|
156
|
+
|
|
152
157
|
const contextPaths =
|
|
153
158
|
executionContext?.fields != null
|
|
154
|
-
? findUploadFieldByPath(executionContext.fields, fieldPath)?.context
|
|
159
|
+
? findUploadFieldByPath(executionContext.fields, fieldPath, formValues)?.context
|
|
155
160
|
: undefined
|
|
156
161
|
|
|
157
|
-
if (contextPaths && contextPaths.length > 0 &&
|
|
158
|
-
const formValues = executionContext.getFormValues()
|
|
162
|
+
if (contextPaths && contextPaths.length > 0 && formValues) {
|
|
159
163
|
for (const contextPath of contextPaths) {
|
|
160
164
|
const resolvedPath = resolveContextPath(fieldPath, contextPath)
|
|
161
165
|
if (resolvedPath === undefined) continue
|
|
@@ -263,43 +267,100 @@ function isRelationEnvelope(value: unknown): value is { targetDocumentId: string
|
|
|
263
267
|
|
|
264
268
|
/**
|
|
265
269
|
* Locate the upload-capable `image | file` schema field addressed by a form
|
|
266
|
-
* field path, walking `group` / `array` / `blocks` structures.
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
270
|
+
* field path, walking `group` / `array` / `blocks` structures.
|
|
271
|
+
*
|
|
272
|
+
* The path is an *instance* path (`content[1].gallery[0].poster`), so it
|
|
273
|
+
* carries no block type — a block item's type lives on the item itself, as
|
|
274
|
+
* `_type`. Passing `formValues` therefore lets a `blocks` hop be resolved
|
|
275
|
+
* exactly: read the addressed item, take its `_type`, descend into that
|
|
276
|
+
* block. Without form values the block type is genuinely unknowable from the
|
|
277
|
+
* path, so every block is tried and the result is accepted only when exactly
|
|
278
|
+
* one resolves — ambiguity returns `undefined` rather than a guess.
|
|
272
279
|
*/
|
|
273
280
|
function findUploadFieldByPath(
|
|
274
281
|
fields: readonly Field[],
|
|
275
|
-
fieldPath: string
|
|
282
|
+
fieldPath: string,
|
|
283
|
+
formValues?: Record<string, any>
|
|
276
284
|
): UploadConfig | undefined {
|
|
277
|
-
const
|
|
285
|
+
const parsed = parseInstancePath(fieldPath)
|
|
286
|
+
if (!parsed.ok) return undefined
|
|
287
|
+
return resolveUploadConfig(fields, parsed.segments, formValues)
|
|
288
|
+
}
|
|
278
289
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
290
|
+
/** Select the item an index / id segment addresses, when data is available. */
|
|
291
|
+
function selectItem(value: unknown, segment: PathSegment): unknown {
|
|
292
|
+
if (!Array.isArray(value)) return undefined
|
|
293
|
+
if (segment.kind === 'index') return value[segment.index]
|
|
294
|
+
if (segment.kind === 'id') {
|
|
295
|
+
return value.find((item) => (item as { _id?: unknown } | null)?._id === segment.id)
|
|
296
|
+
}
|
|
297
|
+
return undefined
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Walk instance-path segments against the schema, carrying the corresponding
|
|
302
|
+
* slice of form data alongside so `blocks` hops can read `_type`.
|
|
303
|
+
*/
|
|
304
|
+
function resolveUploadConfig(
|
|
305
|
+
fields: readonly Field[],
|
|
306
|
+
segments: readonly PathSegment[],
|
|
307
|
+
value: unknown
|
|
308
|
+
): UploadConfig | undefined {
|
|
309
|
+
const head = segments[0]
|
|
310
|
+
if (head == null || head.kind !== 'field') return undefined
|
|
285
311
|
|
|
286
|
-
|
|
287
|
-
|
|
312
|
+
const field = fields.find((candidate) => candidate.name === head.name)
|
|
313
|
+
if (field == null) return undefined
|
|
314
|
+
|
|
315
|
+
const fieldValue = (value as Record<string, unknown> | undefined)?.[head.name]
|
|
316
|
+
const rest = segments.slice(1)
|
|
317
|
+
|
|
318
|
+
if (rest.length === 0) {
|
|
319
|
+
return field.type === 'image' || field.type === 'file' ? field.upload : undefined
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (field.type === 'group') {
|
|
323
|
+
return resolveUploadConfig(field.fields, rest, fieldValue)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (field.type === 'array') {
|
|
327
|
+
// An item selector may be absent when the caller addresses the array's
|
|
328
|
+
// child declaration rather than one item; the child fields are the same.
|
|
329
|
+
const selector = rest[0]
|
|
330
|
+
if (selector?.kind === 'index' || selector?.kind === 'id') {
|
|
331
|
+
return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector))
|
|
288
332
|
}
|
|
333
|
+
return resolveUploadConfig(field.fields, rest, undefined)
|
|
334
|
+
}
|
|
289
335
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
336
|
+
if (field.type === 'blocks') {
|
|
337
|
+
const selector = rest[0]
|
|
338
|
+
const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest
|
|
339
|
+
const item =
|
|
340
|
+
selector?.kind === 'index' || selector?.kind === 'id'
|
|
341
|
+
? selectItem(fieldValue, selector)
|
|
342
|
+
: undefined
|
|
343
|
+
|
|
344
|
+
const blockType = (item as { _type?: unknown } | undefined)?._type
|
|
345
|
+
if (typeof blockType === 'string') {
|
|
346
|
+
const block = field.blocks.find((candidate) => candidate.blockType === blockType)
|
|
347
|
+
return block == null ? undefined : resolveUploadConfig(block.fields, remainder, item)
|
|
293
348
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
349
|
+
|
|
350
|
+
// No data to disambiguate. Try every block and accept a unique answer;
|
|
351
|
+
// two matches mean the path genuinely does not identify one declaration.
|
|
352
|
+
let found: UploadConfig | undefined
|
|
353
|
+
let matches = 0
|
|
354
|
+
for (const block of field.blocks) {
|
|
355
|
+
const result = resolveUploadConfig(block.fields, remainder, undefined)
|
|
356
|
+
if (result !== undefined) {
|
|
357
|
+
matches += 1
|
|
358
|
+
found = result
|
|
359
|
+
}
|
|
300
360
|
}
|
|
301
|
-
return undefined
|
|
361
|
+
return matches === 1 ? found : undefined
|
|
302
362
|
}
|
|
363
|
+
|
|
303
364
|
return undefined
|
|
304
365
|
}
|
|
305
366
|
|