@byline/admin 4.3.0 → 4.4.1
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 +21 -27
- package/dist/fields/blocks/blocks-field.js +19 -17
- 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/file/file-upload-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/fields/image/image-upload-field.js +12 -3
- package/dist/forms/__probe-nested.test.node.d.ts +1 -0
- package/dist/forms/__probe-two.test.node.d.ts +1 -0
- package/dist/forms/form-context.d.ts +22 -2
- package/dist/forms/form-context.js +19 -4
- package/dist/forms/form-renderer.js +3 -1
- package/dist/forms/nested-path.d.ts +5 -1
- package/dist/forms/nested-path.js +84 -15
- package/dist/forms/pending-uploads.d.ts +6 -0
- package/dist/forms/pending-uploads.js +11 -0
- package/dist/forms/pending-uploads.test.node.d.ts +1 -0
- package/dist/forms/repeating-items.d.ts +16 -0
- package/dist/forms/repeating-items.js +28 -0
- package/dist/forms/repeating-items.test.node.d.ts +1 -0
- package/dist/forms/upload-executor.js +50 -29
- package/package.json +5 -5
- package/src/fields/array/array-field.tsx +28 -48
- package/src/fields/blocks/blocks-field.tsx +24 -27
- package/src/fields/code/code-field.tsx +1 -1
- package/src/fields/field-renderer.tsx +0 -7
- package/src/fields/file/file-field.tsx +4 -4
- package/src/fields/file/file-upload-field.tsx +9 -5
- package/src/fields/group/group-field.tsx +0 -8
- package/src/fields/image/image-field.tsx +4 -4
- package/src/fields/image/image-upload-field.tsx +24 -6
- package/src/forms/form-context.tsx +52 -4
- package/src/forms/form-renderer.tsx +3 -1
- package/src/forms/nested-path.test.node.ts +50 -1
- package/src/forms/nested-path.ts +106 -18
- package/src/forms/pending-uploads.test.node.ts +23 -0
- package/src/forms/pending-uploads.ts +22 -0
- package/src/forms/repeating-items.test.node.ts +36 -0
- package/src/forms/repeating-items.ts +48 -0
- package/src/forms/upload-executor.test.node.ts +248 -0
- package/src/forms/upload-executor.ts +94 -30
|
@@ -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
|
|
|
@@ -71,11 +71,15 @@ export const FileUploadField = ({
|
|
|
71
71
|
|
|
72
72
|
const pendingValue = createPendingStoredFileValue(file, previewUrl)
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
if (
|
|
75
|
+
!addPendingUpload(fieldPath, {
|
|
76
|
+
file,
|
|
77
|
+
previewUrl,
|
|
78
|
+
collectionPath,
|
|
79
|
+
})
|
|
80
|
+
) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
79
83
|
|
|
80
84
|
setStatus('idle')
|
|
81
85
|
onUploaded(pendingValue)
|
|
@@ -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.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { ChangeEvent, DragEvent } from 'react'
|
|
21
|
-
import { useCallback, useRef, useState } from 'react'
|
|
21
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
createPendingStoredFileValue,
|
|
@@ -62,12 +62,20 @@ export const ImageUploadField = ({
|
|
|
62
62
|
accept = 'image/*',
|
|
63
63
|
}: ImageUploadFieldProps) => {
|
|
64
64
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
65
|
+
const mountedRef = useRef(true)
|
|
65
66
|
const [status, setStatus] = useState<SelectionStatus>('idle')
|
|
66
67
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
67
68
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
68
69
|
const { addPendingUpload } = useFormContext()
|
|
69
70
|
const { t } = useTranslation('byline-admin')
|
|
70
71
|
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
mountedRef.current = true
|
|
74
|
+
return () => {
|
|
75
|
+
mountedRef.current = false
|
|
76
|
+
}
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
71
79
|
// -------------------------------------------------------------------------
|
|
72
80
|
// Core file selection logic (deferred upload)
|
|
73
81
|
// -------------------------------------------------------------------------
|
|
@@ -90,6 +98,11 @@ export const ImageUploadField = ({
|
|
|
90
98
|
// Extract image dimensions for the pending value
|
|
91
99
|
const img = new Image()
|
|
92
100
|
img.onload = () => {
|
|
101
|
+
if (!mountedRef.current) {
|
|
102
|
+
URL.revokeObjectURL(previewUrl)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
// SVGs without explicit width/height attrs (viewBox-only) report naturalWidth/Height = 0.
|
|
94
107
|
// Skip dimensions when zero so they are stored as null (scalable, no fixed size).
|
|
95
108
|
const w = img.naturalWidth
|
|
@@ -100,11 +113,15 @@ export const ImageUploadField = ({
|
|
|
100
113
|
const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions)
|
|
101
114
|
|
|
102
115
|
// Register the pending upload in form context
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
if (
|
|
117
|
+
!addPendingUpload(fieldPath, {
|
|
118
|
+
file,
|
|
119
|
+
previewUrl,
|
|
120
|
+
collectionPath,
|
|
121
|
+
})
|
|
122
|
+
) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
108
125
|
|
|
109
126
|
setStatus('idle')
|
|
110
127
|
onUploaded(pendingValue)
|
|
@@ -112,6 +129,7 @@ export const ImageUploadField = ({
|
|
|
112
129
|
|
|
113
130
|
img.onerror = () => {
|
|
114
131
|
URL.revokeObjectURL(previewUrl)
|
|
132
|
+
if (!mountedRef.current) return
|
|
115
133
|
setStatus('error')
|
|
116
134
|
setErrorMessage(t('fields.image.upload.errors.cannotRead'))
|
|
117
135
|
}
|
|
@@ -19,7 +19,12 @@ import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
|
19
19
|
// outright. A bare `from 'lodash-es'` import otherwise pools into a single
|
|
20
20
|
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
21
21
|
// reachable from the layout graph).
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
get as getNestedValue,
|
|
24
|
+
hasExistingIdTargets,
|
|
25
|
+
setWithResult as setNestedValue,
|
|
26
|
+
} from './nested-path'
|
|
27
|
+
import { deletePendingUploadsUnderPath } from './pending-uploads'
|
|
23
28
|
import { useTrackedSlot } from './use-tracked-slot'
|
|
24
29
|
|
|
25
30
|
interface FormError {
|
|
@@ -90,6 +95,19 @@ interface FormContextType {
|
|
|
90
95
|
* `@byline/core`).
|
|
91
96
|
*/
|
|
92
97
|
documentId: string | null
|
|
98
|
+
/**
|
|
99
|
+
* Path of the collection this form edits, `null` when the form is rendered
|
|
100
|
+
* without one. Upload widgets need it to address the upload endpoint.
|
|
101
|
+
*
|
|
102
|
+
* It lives here rather than being passed down because it is constant for
|
|
103
|
+
* the whole form: threading it as a prop meant every nesting-capable
|
|
104
|
+
* container (`array`, `group`, `blocks`) had to remember to forward it,
|
|
105
|
+
* and a container that forgot silently rendered upload fields read-only —
|
|
106
|
+
* no error, just a missing drop zone. That happened twice, in `array` /
|
|
107
|
+
* `group` and then in `blocks`. A value read from context cannot be
|
|
108
|
+
* dropped by a container that never carries it.
|
|
109
|
+
*/
|
|
110
|
+
collectionPath: string | null
|
|
93
111
|
setFieldValue: (name: string, value: any) => void
|
|
94
112
|
setFieldStore: (name: string, value: any) => void
|
|
95
113
|
getFieldValue: (name: string) => any
|
|
@@ -117,8 +135,9 @@ interface FormContextType {
|
|
|
117
135
|
subscribeErrors: (listener: ErrorsListener) => () => void
|
|
118
136
|
subscribeMeta: (listener: MetaListener) => () => void
|
|
119
137
|
// Pending uploads (deferred until save)
|
|
120
|
-
addPendingUpload: (fieldPath: string, upload: PendingUpload) =>
|
|
138
|
+
addPendingUpload: (fieldPath: string, upload: PendingUpload) => boolean
|
|
121
139
|
removePendingUpload: (fieldPath: string) => void
|
|
140
|
+
removePendingUploadsUnder: (itemPath: string) => void
|
|
122
141
|
getPendingUploads: () => Map<string, PendingUpload>
|
|
123
142
|
hasPendingUploads: () => boolean
|
|
124
143
|
clearPendingUploads: () => void
|
|
@@ -158,6 +177,7 @@ export const FormProvider = ({
|
|
|
158
177
|
children,
|
|
159
178
|
initialData = {},
|
|
160
179
|
documentId = null,
|
|
180
|
+
collectionPath = null,
|
|
161
181
|
}: {
|
|
162
182
|
children: React.ReactNode
|
|
163
183
|
initialData?: Record<string, any>
|
|
@@ -166,6 +186,12 @@ export const FormProvider = ({
|
|
|
166
186
|
* the context for upload widgets honouring `upload.requireSavedDocument`.
|
|
167
187
|
*/
|
|
168
188
|
documentId?: string | null
|
|
189
|
+
/**
|
|
190
|
+
* Path of the collection being edited. Exposed on the context so upload
|
|
191
|
+
* widgets can reach the upload endpoint from any nesting depth without
|
|
192
|
+
* every container forwarding it — see `FormContextType.collectionPath`.
|
|
193
|
+
*/
|
|
194
|
+
collectionPath?: string | null
|
|
169
195
|
}) => {
|
|
170
196
|
const fieldValues = useRef<Record<string, any>>(
|
|
171
197
|
JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
|
|
@@ -264,13 +290,14 @@ export const FormProvider = ({
|
|
|
264
290
|
const newFieldValues = { ...fieldValues.current }
|
|
265
291
|
|
|
266
292
|
// Keep nested path values up to date for generic usage and patches.
|
|
267
|
-
setNestedValue(newFieldValues, name, value)
|
|
293
|
+
if (!setNestedValue(newFieldValues, name, value)) return false
|
|
268
294
|
|
|
269
295
|
fieldValues.current = newFieldValues
|
|
270
296
|
dirtyFields.current.add(name)
|
|
271
297
|
|
|
272
298
|
notifyFieldListeners(name, value)
|
|
273
299
|
notifyMetaListeners()
|
|
300
|
+
return true
|
|
274
301
|
},
|
|
275
302
|
[notifyFieldListeners, notifyMetaListeners]
|
|
276
303
|
)
|
|
@@ -284,7 +311,7 @@ export const FormProvider = ({
|
|
|
284
311
|
|
|
285
312
|
const setFieldValue = useCallback(
|
|
286
313
|
(name: string, value: any) => {
|
|
287
|
-
updateFieldStoreInternal(name, value)
|
|
314
|
+
if (!updateFieldStoreInternal(name, value)) return
|
|
288
315
|
|
|
289
316
|
const patch: FieldSetPatch = {
|
|
290
317
|
kind: 'field.set',
|
|
@@ -401,6 +428,14 @@ export const FormProvider = ({
|
|
|
401
428
|
|
|
402
429
|
const addPendingUpload = useCallback(
|
|
403
430
|
(fieldPath: string, upload: PendingUpload) => {
|
|
431
|
+
// Image metadata extraction is asynchronous. If its containing item was
|
|
432
|
+
// removed while decoding, discard the late registration rather than
|
|
433
|
+
// allowing submit to recreate or overwrite an item through a stale path.
|
|
434
|
+
if (!hasExistingIdTargets(fieldValues.current, fieldPath)) {
|
|
435
|
+
URL.revokeObjectURL(upload.previewUrl)
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
|
|
404
439
|
// If there's an existing pending upload for this path, revoke its blob URL
|
|
405
440
|
const existing = pendingUploadsRef.current.get(fieldPath)
|
|
406
441
|
if (existing) {
|
|
@@ -409,6 +444,7 @@ export const FormProvider = ({
|
|
|
409
444
|
pendingUploadsRef.current.set(fieldPath, upload)
|
|
410
445
|
dirtyFields.current.add(fieldPath)
|
|
411
446
|
notifyMetaListeners()
|
|
447
|
+
return true
|
|
412
448
|
},
|
|
413
449
|
[notifyMetaListeners]
|
|
414
450
|
)
|
|
@@ -425,6 +461,16 @@ export const FormProvider = ({
|
|
|
425
461
|
[notifyMetaListeners]
|
|
426
462
|
)
|
|
427
463
|
|
|
464
|
+
const removePendingUploadsUnder = useCallback(
|
|
465
|
+
(itemPath: string) => {
|
|
466
|
+
const deleted = deletePendingUploadsUnderPath(pendingUploadsRef.current, itemPath, (url) =>
|
|
467
|
+
URL.revokeObjectURL(url)
|
|
468
|
+
)
|
|
469
|
+
if (deleted) notifyMetaListeners()
|
|
470
|
+
},
|
|
471
|
+
[notifyMetaListeners]
|
|
472
|
+
)
|
|
473
|
+
|
|
428
474
|
const getPendingUploads = useCallback(() => {
|
|
429
475
|
return new Map(pendingUploadsRef.current)
|
|
430
476
|
}, [])
|
|
@@ -662,6 +708,7 @@ export const FormProvider = ({
|
|
|
662
708
|
<FormContext.Provider
|
|
663
709
|
value={{
|
|
664
710
|
documentId,
|
|
711
|
+
collectionPath,
|
|
665
712
|
setFieldValue,
|
|
666
713
|
setFieldStore,
|
|
667
714
|
getFieldValue,
|
|
@@ -687,6 +734,7 @@ export const FormProvider = ({
|
|
|
687
734
|
subscribeMeta,
|
|
688
735
|
addPendingUpload,
|
|
689
736
|
removePendingUpload,
|
|
737
|
+
removePendingUploadsUnder,
|
|
690
738
|
getPendingUploads,
|
|
691
739
|
hasPendingUploads,
|
|
692
740
|
clearPendingUploads,
|
|
@@ -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}
|
|
@@ -552,6 +551,8 @@ const FormContent = ({
|
|
|
552
551
|
noValidate
|
|
553
552
|
onSubmit={handleSubmit}
|
|
554
553
|
className={cx('byline-form', styles.form)}
|
|
554
|
+
inert={isUploading ? true : undefined}
|
|
555
|
+
aria-busy={isUploading}
|
|
555
556
|
>
|
|
556
557
|
<div className={cx('byline-form-heading-row', styles['heading-row'])}>
|
|
557
558
|
<h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
|
|
@@ -778,6 +779,7 @@ export const FormRenderer = ({
|
|
|
778
779
|
key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
|
|
779
780
|
initialData={initialData}
|
|
780
781
|
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
782
|
+
collectionPath={collectionPath ?? null}
|
|
781
783
|
>
|
|
782
784
|
<FormContent
|
|
783
785
|
mode={mode}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from 'vitest'
|
|
10
10
|
|
|
11
|
-
import { get, set, toPath } from './nested-path'
|
|
11
|
+
import { get, hasExistingIdTargets, set, setWithResult, toPath } from './nested-path'
|
|
12
12
|
|
|
13
13
|
describe('toPath', () => {
|
|
14
14
|
it('parses dot and bracket notation', () => {
|
|
@@ -28,6 +28,22 @@ describe('get', () => {
|
|
|
28
28
|
expect(get(obj, 'items[1].title')).toBe('y')
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
it('selects nested repeating items by stable id', () => {
|
|
32
|
+
const value = {
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
_id: 'block-b',
|
|
36
|
+
gallery: [
|
|
37
|
+
{ _id: 'image-1', alt: 'one' },
|
|
38
|
+
{ _id: 'image-2', alt: 'two' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
expect(get(value, 'content[id=block-b].gallery[id=image-2].alt')).toBe('two')
|
|
44
|
+
expect(get(value, 'content[id=missing].gallery[id=image-2].alt')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
31
47
|
it('preserves falsy values (does not conflate with missing)', () => {
|
|
32
48
|
expect(get(obj, 'a.zero')).toBe(0)
|
|
33
49
|
expect(get(obj, 'a.empty')).toBe('')
|
|
@@ -78,8 +94,41 @@ describe('set', () => {
|
|
|
78
94
|
expect(Array.isArray(o.items)).toBe(true)
|
|
79
95
|
})
|
|
80
96
|
|
|
97
|
+
it('writes through nested stable-id selectors without changing array order', () => {
|
|
98
|
+
const o = {
|
|
99
|
+
content: [
|
|
100
|
+
{ _id: 'a', gallery: [{ _id: 'x', alt: 'keep' }] },
|
|
101
|
+
{ _id: 'b', gallery: [{ _id: 'y', alt: 'old' }] },
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
set(o, 'content[id=b].gallery[id=y].alt', 'new')
|
|
105
|
+
expect(o.content.map((item) => item._id)).toEqual(['a', 'b'])
|
|
106
|
+
expect(o.content[0]?.gallery[0]?.alt).toBe('keep')
|
|
107
|
+
expect(o.content[1]?.gallery[0]?.alt).toBe('new')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not create a ghost item for an unknown stable id', () => {
|
|
111
|
+
const o = { items: [{ _id: 'a', title: 'keep' }] }
|
|
112
|
+
expect(setWithResult(o, 'items[id=gone].title', 'new')).toBe(false)
|
|
113
|
+
expect(o).toEqual({ items: [{ _id: 'a', title: 'keep' }] })
|
|
114
|
+
|
|
115
|
+
const missingContainer: { group: { items?: unknown[] } } = { group: {} }
|
|
116
|
+
expect(setWithResult(missingContainer, 'group.items[id=gone].title', 'new')).toBe(false)
|
|
117
|
+
expect(missingContainer).toEqual({ group: {} })
|
|
118
|
+
})
|
|
119
|
+
|
|
81
120
|
it('returns the mutated root', () => {
|
|
82
121
|
const o: any = {}
|
|
83
122
|
expect(set(o, 'x', 1)).toBe(o)
|
|
84
123
|
})
|
|
85
124
|
})
|
|
125
|
+
|
|
126
|
+
describe('hasExistingIdTargets', () => {
|
|
127
|
+
const value = { items: [{ _id: 'a', nested: [{ _id: 'b' }] }] }
|
|
128
|
+
|
|
129
|
+
it('accepts live nested identities and rejects removed ones', () => {
|
|
130
|
+
expect(hasExistingIdTargets(value, 'items[id=a].nested[id=b].title')).toBe(true)
|
|
131
|
+
expect(hasExistingIdTargets(value, 'items[id=a].nested[id=gone].title')).toBe(false)
|
|
132
|
+
expect(hasExistingIdTargets(value, 'title')).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|
package/src/forms/nested-path.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Minimal nested `get`/`set` over string field paths, replacing lodash-es
|
|
9
9
|
* (which pulled a large shared chunk onto unrelated bundles). Supports the
|
|
10
10
|
* dot + bracket notation produced by the form field-path builders, e.g.
|
|
11
|
-
* `title`, `a.b.c`, `items[0].title`, `blocks[
|
|
11
|
+
* `title`, `a.b.c`, `items[0].title`, `blocks[id=abc].nested[1].field`.
|
|
12
12
|
*
|
|
13
13
|
* `set` mirrors lodash semantics: it creates intermediate **arrays** when the
|
|
14
14
|
* next path segment is a numeric index and plain **objects** otherwise, and it
|
|
@@ -19,42 +19,130 @@
|
|
|
19
19
|
* form paths ever produce. See nested-path.test.node.ts for the covered cases.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
import { type PathSegment, parseInstancePath } from '@byline/core'
|
|
23
23
|
|
|
24
24
|
/** Split a field path into segments: `items[0].title` -> ['items','0','title']. */
|
|
25
25
|
export function toPath(path: string): string[] {
|
|
26
26
|
return path.match(/[^.[\]]+/g) ?? []
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function selectId(value: unknown, id: string): number {
|
|
30
|
+
if (!Array.isArray(value)) return -1
|
|
31
|
+
return value.findIndex((item) => item != null && typeof item === 'object' && item._id === id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function newContainer(next: PathSegment | undefined): any[] | Record<string, unknown> {
|
|
35
|
+
return next?.kind === 'index' || next?.kind === 'id' ? [] : {}
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
// Returns `any` (not `T | undefined`) to match lodash's loose `get` contract,
|
|
30
39
|
// so existing call sites that treat the result as `any` keep type-checking.
|
|
31
40
|
export function get<T = any>(object: unknown, path: string): T {
|
|
32
41
|
if (object == null) return undefined as T
|
|
42
|
+
const parsed = parseInstancePath(path)
|
|
43
|
+
if (!parsed.ok) return undefined as T
|
|
44
|
+
|
|
33
45
|
let current: any = object
|
|
34
|
-
for (const
|
|
46
|
+
for (const segment of parsed.segments) {
|
|
35
47
|
if (current == null) return undefined as T
|
|
36
|
-
|
|
48
|
+
if (segment.kind === 'field') {
|
|
49
|
+
current = current[segment.name]
|
|
50
|
+
} else if (segment.kind === 'index') {
|
|
51
|
+
current = current[segment.index]
|
|
52
|
+
} else if (segment.kind === 'id') {
|
|
53
|
+
const index = selectId(current, segment.id)
|
|
54
|
+
if (index === -1) return undefined as T
|
|
55
|
+
current = current[index]
|
|
56
|
+
} else {
|
|
57
|
+
return undefined as T
|
|
58
|
+
}
|
|
37
59
|
}
|
|
38
60
|
return current as T
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
63
|
+
/** Whether every stable-id selector in a path still identifies a live item. */
|
|
64
|
+
export function hasExistingIdTargets(object: unknown, path: string): boolean {
|
|
65
|
+
const parsed = parseInstancePath(path)
|
|
66
|
+
if (!parsed.ok) return false
|
|
67
|
+
|
|
68
|
+
let current: any = object
|
|
69
|
+
for (const segment of parsed.segments) {
|
|
70
|
+
if (segment.kind === 'field') {
|
|
71
|
+
current = current?.[segment.name]
|
|
72
|
+
} else if (segment.kind === 'index') {
|
|
73
|
+
current = current?.[segment.index]
|
|
74
|
+
} else if (segment.kind === 'id') {
|
|
75
|
+
const index = selectId(current, segment.id)
|
|
76
|
+
if (index === -1) return false
|
|
77
|
+
current = current[index]
|
|
78
|
+
} else {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Set a path and report whether all stable-id selectors resolved. */
|
|
86
|
+
export function setWithResult<T extends object>(object: T, path: string, value: unknown): boolean {
|
|
87
|
+
if (object == null) return false
|
|
88
|
+
const parsed = parseInstancePath(path)
|
|
89
|
+
if (!parsed.ok || parsed.segments.length === 0) return false
|
|
45
90
|
|
|
46
91
|
let current: any = object
|
|
47
|
-
for (let i = 0; i <
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
for (let i = 0; i < parsed.segments.length; i++) {
|
|
93
|
+
const segment = parsed.segments[i] as PathSegment
|
|
94
|
+
const next = parsed.segments[i + 1]
|
|
95
|
+
const last = i === parsed.segments.length - 1
|
|
96
|
+
|
|
97
|
+
if (segment.kind === 'field') {
|
|
98
|
+
if (last) {
|
|
99
|
+
current[segment.name] = value
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
const existing = current[segment.name]
|
|
103
|
+
if (existing == null || typeof existing !== 'object') {
|
|
104
|
+
// Do not create a partial container on the way to an item identity
|
|
105
|
+
// that may no longer exist. Normal non-ID lodash-style writes still
|
|
106
|
+
// create their intermediate structure below.
|
|
107
|
+
if (parsed.segments.slice(i + 1).some((candidate) => candidate.kind === 'id')) return false
|
|
108
|
+
current[segment.name] = newContainer(next)
|
|
109
|
+
}
|
|
110
|
+
current = current[segment.name]
|
|
111
|
+
continue
|
|
55
112
|
}
|
|
56
|
-
|
|
113
|
+
|
|
114
|
+
if (segment.kind === 'index') {
|
|
115
|
+
if (!Array.isArray(current)) return false
|
|
116
|
+
if (last) {
|
|
117
|
+
current[segment.index] = value
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
const existing = current[segment.index]
|
|
121
|
+
if (existing == null || typeof existing !== 'object') {
|
|
122
|
+
if (parsed.segments.slice(i + 1).some((candidate) => candidate.kind === 'id')) return false
|
|
123
|
+
current[segment.index] = newContainer(next)
|
|
124
|
+
}
|
|
125
|
+
current = current[segment.index]
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (segment.kind === 'id') {
|
|
130
|
+
const index = selectId(current, segment.id)
|
|
131
|
+
if (index === -1) return false
|
|
132
|
+
if (last) {
|
|
133
|
+
current[index] = value
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
current = current[index]
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return false
|
|
57
141
|
}
|
|
58
|
-
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function set<T extends object>(object: T, path: string, value: unknown): T {
|
|
146
|
+
setWithResult(object, path, value)
|
|
59
147
|
return object
|
|
60
148
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { deletePendingUploadsUnderPath } from './pending-uploads'
|
|
4
|
+
|
|
5
|
+
describe('deletePendingUploadsUnderPath', () => {
|
|
6
|
+
it('removes and revokes only uploads beneath the removed item', () => {
|
|
7
|
+
const uploads = new Map([
|
|
8
|
+
['content[id=a].image', { previewUrl: 'blob:a' }],
|
|
9
|
+
['content[id=b].image', { previewUrl: 'blob:b' }],
|
|
10
|
+
['content[id=b].gallery[id=x].image', { previewUrl: 'blob:nested' }],
|
|
11
|
+
])
|
|
12
|
+
const revoke = vi.fn()
|
|
13
|
+
|
|
14
|
+
expect(deletePendingUploadsUnderPath(uploads, 'content[id=b]', revoke)).toBe(true)
|
|
15
|
+
expect([...uploads.keys()]).toEqual(['content[id=a].image'])
|
|
16
|
+
expect(revoke.mock.calls).toEqual([['blob:b'], ['blob:nested']])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('reports when no pending upload matched', () => {
|
|
20
|
+
const uploads = new Map([['content[id=a].image', { previewUrl: 'blob:a' }]])
|
|
21
|
+
expect(deletePendingUploadsUnderPath(uploads, 'content[id=b]', vi.fn())).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface PendingUploadLike {
|
|
2
|
+
previewUrl: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Remove deferred uploads belonging to one repeating item and its descendants. */
|
|
6
|
+
export function deletePendingUploadsUnderPath<T extends PendingUploadLike>(
|
|
7
|
+
uploads: Map<string, T>,
|
|
8
|
+
itemPath: string,
|
|
9
|
+
revokeObjectURL: (url: string) => void
|
|
10
|
+
): boolean {
|
|
11
|
+
const descendantPrefix = `${itemPath}.`
|
|
12
|
+
let deleted = false
|
|
13
|
+
|
|
14
|
+
for (const [fieldPath, upload] of uploads) {
|
|
15
|
+
if (fieldPath !== itemPath && !fieldPath.startsWith(descendantPrefix)) continue
|
|
16
|
+
revokeObjectURL(upload.previewUrl)
|
|
17
|
+
uploads.delete(fieldPath)
|
|
18
|
+
deleted = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return deleted
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from './repeating-items'
|
|
4
|
+
|
|
5
|
+
describe('repeatingItemPath', () => {
|
|
6
|
+
it('uses stable storage identity when available', () => {
|
|
7
|
+
expect(repeatingItemPath('content', { _id: 'block-b' }, 1)).toBe('content[id=block-b]')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('falls back to position for id-less or path-unsafe identities', () => {
|
|
11
|
+
expect(repeatingItemPath('content', {}, 1)).toBe('content[1]')
|
|
12
|
+
expect(repeatingItemPath('content', { _id: 'unsafe.id' }, 1)).toBe('content[1]')
|
|
13
|
+
expect(repeatingItemPath('content', { _id: null }, 1)).toBe('content[1]')
|
|
14
|
+
expect(repeatingItemId({ _id: 42 })).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('moveRepeatingItems', () => {
|
|
19
|
+
const ids = (items: { _id: string }[]) => items.map((item) => item._id)
|
|
20
|
+
|
|
21
|
+
it('keeps consecutive moves aligned with the current form-store order', () => {
|
|
22
|
+
const first = moveRepeatingItems([{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], 2, 0)
|
|
23
|
+
expect(first?.itemId).toBe('c')
|
|
24
|
+
expect(ids(first?.items ?? [])).toEqual(['c', 'a', 'b'])
|
|
25
|
+
|
|
26
|
+
const second = moveRepeatingItems(first?.items ?? [], 2, 1)
|
|
27
|
+
expect(second?.itemId).toBe('b')
|
|
28
|
+
expect(ids(second?.items ?? [])).toEqual(['c', 'b', 'a'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('retains the positional patch fallback for id-less items', () => {
|
|
32
|
+
const moved = moveRepeatingItems([{ value: 'a' }, { value: 'b' }], 1, 0)
|
|
33
|
+
expect(moved?.itemId).toBe('1')
|
|
34
|
+
expect(moved?.items.map((item) => item.value)).toEqual(['b', 'a'])
|
|
35
|
+
})
|
|
36
|
+
})
|