@byline/admin 4.2.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 +12 -9
- package/dist/fields/array/array-field.js +46 -34
- package/dist/fields/blocks/blocks-field.js +1 -0
- package/dist/fields/draggable-context-menu.js +2 -1
- package/dist/fields/field-admin.d.ts +15 -0
- package/dist/fields/field-admin.js +11 -0
- package/dist/fields/field-admin.test.node.d.ts +8 -0
- package/dist/fields/field-renderer.d.ts +11 -4
- package/dist/fields/field-renderer.js +8 -9
- 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 +16 -9
- package/dist/fields/group/group-field.js +5 -4
- package/dist/fields/image/image-field.d.ts +1 -3
- package/dist/fields/image/image-field.js +2 -2
- package/dist/fields/sortable-item.d.ts +6 -0
- package/dist/fields/sortable-item.js +44 -25
- package/dist/forms/form-context.d.ts +20 -1
- package/dist/forms/form-context.js +3 -2
- package/dist/forms/form-renderer.js +4 -2
- package/dist/forms/upload-executor.js +50 -29
- package/package.json +10 -10
- package/src/fields/array/array-field.tsx +74 -43
- package/src/fields/blocks/blocks-field.tsx +5 -0
- package/src/fields/draggable-context-menu.tsx +9 -1
- package/src/fields/field-admin.test.node.ts +49 -0
- package/src/fields/field-admin.ts +39 -0
- package/src/fields/field-renderer.tsx +14 -7
- package/src/fields/file/file-field.tsx +4 -4
- package/src/fields/group/group-field.tsx +19 -11
- package/src/fields/image/image-field.tsx +4 -4
- package/src/fields/sortable-item.tsx +115 -34
- package/src/forms/form-context.tsx +30 -1
- package/src/forms/form-renderer.tsx +3 -1
- package/src/forms/upload-executor.test.node.ts +187 -0
- package/src/forms/upload-executor.ts +90 -29
|
@@ -12,6 +12,7 @@ import type { Field, FieldAdminConfig, GroupField as GroupFieldType } from '@byl
|
|
|
12
12
|
import { ErrorText } from '@byline/ui/react'
|
|
13
13
|
import cx from 'classnames'
|
|
14
14
|
|
|
15
|
+
import { sliceFieldAdmin } from '../../fields/field-admin'
|
|
15
16
|
import { placeholderForField } from '../../fields/field-helpers'
|
|
16
17
|
import { FieldRenderer } from '../../fields/field-renderer'
|
|
17
18
|
import { useFieldError } from '../../forms/form-context'
|
|
@@ -33,11 +34,15 @@ interface GroupFieldProps {
|
|
|
33
34
|
defaultValue: any
|
|
34
35
|
path: string
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* Threaded to child fields — governs only the *drag* affordance of any
|
|
38
|
+
* `array` children (structural add/remove always renders; see ArrayField).
|
|
39
|
+
* Defaults to `true` (conservative): arrays inside plain schema groups
|
|
40
|
+
* stay drag-free. `BlocksField` passes `false` on its synthesized group so
|
|
41
|
+
* arrays directly inside blocks are fully sortable — safe because each
|
|
42
|
+
* `DraggableSortable` is an independent DndContext with grip-scoped
|
|
43
|
+
* listeners.
|
|
39
44
|
*/
|
|
40
|
-
|
|
45
|
+
disableSorting?: boolean
|
|
41
46
|
/**
|
|
42
47
|
* Active content locale, forwarded to child fields so localized widgets
|
|
43
48
|
* nested inside the group (e.g. a `localized` richText) can render their
|
|
@@ -46,10 +51,13 @@ interface GroupFieldProps {
|
|
|
46
51
|
contentLocale?: string
|
|
47
52
|
/**
|
|
48
53
|
* Per-child-field admin overrides (`components` slots, richtext `editor`),
|
|
49
|
-
* keyed by
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* keyed by dotted, index-free schema paths relative to this group
|
|
55
|
+
* ('caption', 'faq.answer'). Threaded by `BlocksField` from the site-wide
|
|
56
|
+
* `ClientConfig.blockAdmin` registry (block children render through a
|
|
57
|
+
* synthesized group) and by `FieldRenderer` for plain schema groups, whose
|
|
58
|
+
* map arrives pre-sliced from the collection admin config. Exact-name
|
|
59
|
+
* entries apply to the child itself; deeper entries are re-sliced and
|
|
60
|
+
* threaded on (see `sliceFieldAdmin`).
|
|
53
61
|
*/
|
|
54
62
|
fieldAdmin?: Record<string, FieldAdminConfig>
|
|
55
63
|
}
|
|
@@ -58,7 +66,7 @@ export const GroupField = ({
|
|
|
58
66
|
field,
|
|
59
67
|
defaultValue,
|
|
60
68
|
path,
|
|
61
|
-
|
|
69
|
+
disableSorting = true,
|
|
62
70
|
contentLocale,
|
|
63
71
|
fieldAdmin,
|
|
64
72
|
}: GroupFieldProps) => {
|
|
@@ -100,11 +108,11 @@ export const GroupField = ({
|
|
|
100
108
|
field={innerField}
|
|
101
109
|
defaultValue={groupData[innerField.name]}
|
|
102
110
|
basePath={path}
|
|
103
|
-
disableSorting={
|
|
104
|
-
collectionPath={collectionPath}
|
|
111
|
+
disableSorting={disableSorting}
|
|
105
112
|
contentLocale={contentLocale}
|
|
106
113
|
components={fieldAdmin?.[innerField.name]?.components}
|
|
107
114
|
editor={fieldAdmin?.[innerField.name]?.editor}
|
|
115
|
+
fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}
|
|
108
116
|
/>
|
|
109
117
|
)
|
|
110
118
|
})}
|
|
@@ -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.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { type ReactNode, useState } from 'react'
|
|
9
|
+
import { type CSSProperties, type ReactNode, type Ref, useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import { useTranslation } from '@byline/i18n/react'
|
|
12
12
|
import { ChevronDownIcon, GripperVerticalIcon, useSortable } from '@byline/ui/react'
|
|
@@ -15,44 +15,60 @@ import cx from 'classnames'
|
|
|
15
15
|
import { DraggableContextMenu } from './draggable-context-menu'
|
|
16
16
|
import styles from './sortable-item.module.css'
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Item chrome for repeating-structure entries (array items, block instances):
|
|
20
|
+
// a header carrying the label, the add-below/remove context menu, and a
|
|
21
|
+
// collapse toggle, above the item's rendered fields.
|
|
22
|
+
//
|
|
23
|
+
// Two variants share the frame:
|
|
24
|
+
// - `SortableItem` — adds the dnd-kit grip (drag handle). Must render
|
|
25
|
+
// inside a `DraggableSortable` (it calls `useSortable`).
|
|
26
|
+
// - `StaticItem` — no grip, no dnd hook. For contexts where drag is
|
|
27
|
+
// disabled but structural editing (add/remove/collapse) must remain,
|
|
28
|
+
// e.g. arrays nested inside another array's items.
|
|
29
|
+
//
|
|
30
|
+
// They are separate components (not a boolean prop on one component)
|
|
31
|
+
// because `useSortable` is a hook — it cannot be called conditionally, and
|
|
32
|
+
// it throws outside a DndContext.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface ItemFrameProps {
|
|
26
36
|
label: ReactNode
|
|
27
37
|
children: ReactNode
|
|
28
38
|
onAddBelow?: () => void
|
|
29
39
|
onRemove?: () => void
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
})
|
|
40
|
+
/** Drag handle slot — rendered leading the header when provided. */
|
|
41
|
+
grip?: ReactNode
|
|
42
|
+
rootRef?: Ref<HTMLDivElement>
|
|
43
|
+
style?: CSSProperties
|
|
44
|
+
dragging?: boolean
|
|
45
|
+
/** Extra root class, e.g. the static variant marker. */
|
|
46
|
+
rootClassName?: string
|
|
47
|
+
}
|
|
39
48
|
|
|
49
|
+
const ItemFrame = ({
|
|
50
|
+
label,
|
|
51
|
+
children,
|
|
52
|
+
onAddBelow,
|
|
53
|
+
onRemove,
|
|
54
|
+
grip,
|
|
55
|
+
rootRef,
|
|
56
|
+
style,
|
|
57
|
+
dragging = false,
|
|
58
|
+
rootClassName,
|
|
59
|
+
}: ItemFrameProps) => {
|
|
60
|
+
const { t } = useTranslation('byline-admin')
|
|
40
61
|
const [collapsed, setCollapsed] = useState(false)
|
|
41
62
|
|
|
42
|
-
const style = {
|
|
43
|
-
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
|
44
|
-
transition,
|
|
45
|
-
zIndex: isDragging ? 10 : 'auto',
|
|
46
|
-
}
|
|
47
|
-
|
|
48
63
|
return (
|
|
49
64
|
<div
|
|
50
|
-
ref={
|
|
65
|
+
ref={rootRef}
|
|
51
66
|
style={style}
|
|
52
67
|
className={cx(
|
|
53
68
|
'byline-sortable',
|
|
54
69
|
styles.root,
|
|
55
|
-
|
|
70
|
+
rootClassName,
|
|
71
|
+
dragging && ['byline-sortable-dragging', styles.dragging],
|
|
56
72
|
collapsed && ['byline-sortable-collapsed', styles.collapsed]
|
|
57
73
|
)}
|
|
58
74
|
>
|
|
@@ -63,14 +79,7 @@ export const SortableItem = ({
|
|
|
63
79
|
!collapsed && ['byline-sortable-header-expanded', styles['header-expanded']]
|
|
64
80
|
)}
|
|
65
81
|
>
|
|
66
|
-
|
|
67
|
-
type="button"
|
|
68
|
-
className={cx('byline-sortable-grip', styles.grip)}
|
|
69
|
-
{...attributes}
|
|
70
|
-
{...listeners}
|
|
71
|
-
>
|
|
72
|
-
<GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
|
|
73
|
-
</button>
|
|
82
|
+
{grip}
|
|
74
83
|
<div className={cx('byline-sortable-label', styles.label)}>{label}</div>
|
|
75
84
|
<DraggableContextMenu onAddBelow={onAddBelow} onRemove={onRemove} />
|
|
76
85
|
<button
|
|
@@ -104,3 +113,75 @@ export const SortableItem = ({
|
|
|
104
113
|
</div>
|
|
105
114
|
)
|
|
106
115
|
}
|
|
116
|
+
|
|
117
|
+
export const SortableItem = ({
|
|
118
|
+
id,
|
|
119
|
+
label,
|
|
120
|
+
children,
|
|
121
|
+
onAddBelow,
|
|
122
|
+
onRemove,
|
|
123
|
+
}: {
|
|
124
|
+
id: string
|
|
125
|
+
label: ReactNode
|
|
126
|
+
children: ReactNode
|
|
127
|
+
onAddBelow?: () => void
|
|
128
|
+
onRemove?: () => void
|
|
129
|
+
}) => {
|
|
130
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
131
|
+
id,
|
|
132
|
+
transition: {
|
|
133
|
+
duration: 250,
|
|
134
|
+
easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const style = {
|
|
139
|
+
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
|
140
|
+
transition,
|
|
141
|
+
zIndex: isDragging ? 10 : 'auto',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<ItemFrame
|
|
146
|
+
label={label}
|
|
147
|
+
onAddBelow={onAddBelow}
|
|
148
|
+
onRemove={onRemove}
|
|
149
|
+
rootRef={setNodeRef}
|
|
150
|
+
style={style}
|
|
151
|
+
dragging={isDragging}
|
|
152
|
+
grip={
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className={cx('byline-sortable-grip', styles.grip)}
|
|
156
|
+
{...attributes}
|
|
157
|
+
{...listeners}
|
|
158
|
+
>
|
|
159
|
+
<GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
|
|
160
|
+
</button>
|
|
161
|
+
}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</ItemFrame>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const StaticItem = ({
|
|
169
|
+
label,
|
|
170
|
+
children,
|
|
171
|
+
onAddBelow,
|
|
172
|
+
onRemove,
|
|
173
|
+
}: {
|
|
174
|
+
label: ReactNode
|
|
175
|
+
children: ReactNode
|
|
176
|
+
onAddBelow?: () => void
|
|
177
|
+
onRemove?: () => void
|
|
178
|
+
}) => (
|
|
179
|
+
<ItemFrame
|
|
180
|
+
label={label}
|
|
181
|
+
onAddBelow={onAddBelow}
|
|
182
|
+
onRemove={onRemove}
|
|
183
|
+
rootClassName="byline-sortable-static"
|
|
184
|
+
>
|
|
185
|
+
{children}
|
|
186
|
+
</ItemFrame>
|
|
187
|
+
)
|
|
@@ -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))
|
|
@@ -316,7 +336,15 @@ export const FormProvider = ({
|
|
|
316
336
|
const getPatches = useCallback(() => patchesRef.current, [])
|
|
317
337
|
const appendPatch = useCallback(
|
|
318
338
|
(patch: DocumentPatch) => {
|
|
319
|
-
|
|
339
|
+
// Snapshot the patch at append time. Structural patches (array.insert,
|
|
340
|
+
// block add) carry item objects that are ALSO placed into the form
|
|
341
|
+
// store — and `setNestedValue` mutates store nodes in place, so a
|
|
342
|
+
// later nested write inside the item (e.g. adding an array item to a
|
|
343
|
+
// block added this session) would silently rewrite the queued patch.
|
|
344
|
+
// Serialized at save time, the block insert would then already contain
|
|
345
|
+
// the array items AND the array.insert patches would re-add them —
|
|
346
|
+
// duplicating items server-side (caught by e2e/array-in-block.spec.ts).
|
|
347
|
+
patchesRef.current = [...patchesRef.current, structuredClone(patch)]
|
|
320
348
|
// Mark a generic dirty flag so hasChanges() becomes true even
|
|
321
349
|
// for patches that don't correspond to a specific field.set.
|
|
322
350
|
dirtyFields.current.add('__patch__')
|
|
@@ -654,6 +682,7 @@ export const FormProvider = ({
|
|
|
654
682
|
<FormContext.Provider
|
|
655
683
|
value={{
|
|
656
684
|
documentId,
|
|
685
|
+
collectionPath,
|
|
657
686
|
setFieldValue,
|
|
658
687
|
setFieldStore,
|
|
659
688
|
getFieldValue,
|
|
@@ -24,6 +24,7 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
24
24
|
import { Alert, Button, ComboButton } from '@byline/ui/react'
|
|
25
25
|
import cx from 'classnames'
|
|
26
26
|
|
|
27
|
+
import { sliceFieldAdmin } from '../fields/field-admin'
|
|
27
28
|
import { FieldRenderer } from '../fields/field-renderer'
|
|
28
29
|
import { useBylineFieldServices } from '../fields/field-services-context'
|
|
29
30
|
import { AdminGroup } from '../presentation/group'
|
|
@@ -484,10 +485,10 @@ const FormContent = ({
|
|
|
484
485
|
key={field.name}
|
|
485
486
|
field={field}
|
|
486
487
|
defaultValue={initialData?.fields?.[field.name]}
|
|
487
|
-
collectionPath={collectionPath}
|
|
488
488
|
contentLocale={contentLocale}
|
|
489
489
|
components={adminConfig?.fields?.[field.name]?.components}
|
|
490
490
|
editor={adminConfig?.fields?.[field.name]?.editor}
|
|
491
|
+
fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
|
|
491
492
|
/>
|
|
492
493
|
)
|
|
493
494
|
}
|
|
@@ -776,6 +777,7 @@ export const FormRenderer = ({
|
|
|
776
777
|
key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
|
|
777
778
|
initialData={initialData}
|
|
778
779
|
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
780
|
+
collectionPath={collectionPath ?? null}
|
|
779
781
|
>
|
|
780
782
|
<FormContent
|
|
781
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
|
+
})
|