@byline/admin 4.4.0 → 4.5.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.js +20 -24
- package/dist/fields/blocks/blocks-field.js +19 -17
- package/dist/fields/file/file-upload-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 +2 -1
- package/dist/forms/form-context.js +17 -3
- package/dist/forms/form-renderer.js +2 -0
- 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/package.json +5 -5
- package/src/fields/array/array-field.tsx +28 -38
- package/src/fields/blocks/blocks-field.tsx +24 -27
- package/src/fields/code/code-field.tsx +1 -1
- package/src/fields/file/file-upload-field.tsx +9 -5
- package/src/fields/image/image-upload-field.tsx +24 -6
- package/src/forms/form-context.tsx +31 -4
- package/src/forms/form-renderer.tsx +2 -0
- 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 +64 -3
- package/src/forms/upload-executor.ts +4 -1
|
@@ -18,6 +18,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
|
|
|
18
18
|
import { FieldRenderer } from '../../fields/field-renderer'
|
|
19
19
|
import { SortableItem, StaticItem } from '../../fields/sortable-item'
|
|
20
20
|
import { useFormContext } from '../../forms/form-context'
|
|
21
|
+
import { hasExistingIdTargets } from '../../forms/nested-path'
|
|
22
|
+
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
|
|
21
23
|
import styles from './array-field.module.css'
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
@@ -55,7 +57,8 @@ export const ArrayField = ({
|
|
|
55
57
|
*/
|
|
56
58
|
fieldAdmin?: Record<string, FieldAdminConfig>
|
|
57
59
|
}) => {
|
|
58
|
-
const { appendPatch, getFieldValue, getFieldValues, setFieldStore } =
|
|
60
|
+
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
|
|
61
|
+
useFormContext()
|
|
59
62
|
const { t } = useTranslation('byline-admin')
|
|
60
63
|
const [items, setItems] = useState<{ id: string; data: any }[]>([])
|
|
61
64
|
|
|
@@ -72,10 +75,10 @@ export const ArrayField = ({
|
|
|
72
75
|
setItems(
|
|
73
76
|
source.map((item: any) => ({
|
|
74
77
|
id:
|
|
75
|
-
item && typeof item === 'object' && '
|
|
76
|
-
? String((item as {
|
|
77
|
-
: item && typeof item === 'object' && '
|
|
78
|
-
? String((item as {
|
|
78
|
+
item && typeof item === 'object' && '_id' in item
|
|
79
|
+
? String((item as { _id: string })._id)
|
|
80
|
+
: item && typeof item === 'object' && 'id' in item
|
|
81
|
+
? String((item as { id: string }).id)
|
|
79
82
|
: crypto.randomUUID(),
|
|
80
83
|
data: item,
|
|
81
84
|
}))
|
|
@@ -85,23 +88,6 @@ export const ArrayField = ({
|
|
|
85
88
|
}
|
|
86
89
|
}, [defaultValue, getFieldValue, path])
|
|
87
90
|
|
|
88
|
-
/**
|
|
89
|
-
* Stable patch identity for an array item. Persisted items carry `_id`
|
|
90
|
-
* (the array-item identity from `store_meta`) — that is what the server's
|
|
91
|
-
* patch engine matches on (`applyArrayPatch`: `item._id === patch.itemId`),
|
|
92
|
-
* so it MUST be preferred here. `id` is accepted as a legacy/seed-data
|
|
93
|
-
* alias. Items added this session have neither (the storage layer assigns
|
|
94
|
-
* `_id` at write time), so fall back to the item's current index — the
|
|
95
|
-
* patch engine resolves a pure-integer itemId as an index fallback.
|
|
96
|
-
*/
|
|
97
|
-
const patchItemId = (item: unknown, index: number): string => {
|
|
98
|
-
if (item && typeof item === 'object') {
|
|
99
|
-
if ('_id' in item) return String((item as { _id: string })._id)
|
|
100
|
-
if ('id' in item) return String((item as { id: string }).id)
|
|
101
|
-
}
|
|
102
|
-
return String(index)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
91
|
const handleDragEnd = ({
|
|
106
92
|
moveFromIndex,
|
|
107
93
|
moveToIndex,
|
|
@@ -109,23 +95,20 @@ export const ArrayField = ({
|
|
|
109
95
|
moveFromIndex: number
|
|
110
96
|
moveToIndex: number
|
|
111
97
|
}) => {
|
|
112
|
-
setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
|
|
113
98
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
99
|
+
if (!Array.isArray(currentArray)) return
|
|
114
100
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1))
|
|
118
|
-
if (clampedFrom === clampedTo) return
|
|
101
|
+
const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
|
|
102
|
+
if (move == null) return
|
|
119
103
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
104
|
+
setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
|
|
105
|
+
setFieldStore(path, move.items)
|
|
106
|
+
appendPatch({
|
|
107
|
+
kind: 'array.move',
|
|
108
|
+
path,
|
|
109
|
+
itemId: move.itemId,
|
|
110
|
+
toIndex: move.toIndex,
|
|
111
|
+
})
|
|
129
112
|
}
|
|
130
113
|
|
|
131
114
|
const handleAddItem = async (atIndex?: number) => {
|
|
@@ -153,6 +136,11 @@ export const ArrayField = ({
|
|
|
153
136
|
}
|
|
154
137
|
}
|
|
155
138
|
|
|
139
|
+
// Defaults may resolve asynchronously. If an enclosing stable-id item was
|
|
140
|
+
// removed in the meantime, do not append a structural patch that would
|
|
141
|
+
// recreate that missing parent on the server.
|
|
142
|
+
if (!hasExistingIdTargets(getFieldValues(), path)) return
|
|
143
|
+
|
|
156
144
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
157
145
|
const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
|
|
158
146
|
|
|
@@ -180,13 +168,15 @@ export const ArrayField = ({
|
|
|
180
168
|
if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
|
|
181
169
|
|
|
182
170
|
const item = currentArray[index]
|
|
171
|
+
const itemPath = repeatingItemPath(path, item, index)
|
|
183
172
|
|
|
184
173
|
setItems((prev) => prev.filter((_, i) => i !== index))
|
|
174
|
+
removePendingUploadsUnder(itemPath)
|
|
185
175
|
|
|
186
176
|
appendPatch({
|
|
187
177
|
kind: 'array.remove',
|
|
188
178
|
path: path,
|
|
189
|
-
itemId:
|
|
179
|
+
itemId: repeatingItemId(item) ?? String(index),
|
|
190
180
|
})
|
|
191
181
|
|
|
192
182
|
const newArrayValue = [...currentArray]
|
|
@@ -200,7 +190,7 @@ export const ArrayField = ({
|
|
|
200
190
|
|
|
201
191
|
const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
|
|
202
192
|
const item = itemWrapper.data
|
|
203
|
-
const arrayElementPath =
|
|
193
|
+
const arrayElementPath = repeatingItemPath(path, item, index)
|
|
204
194
|
|
|
205
195
|
if (!item || typeof item !== 'object') return null
|
|
206
196
|
|
|
@@ -31,6 +31,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
|
|
|
31
31
|
import { GroupField } from '../../fields/group/group-field'
|
|
32
32
|
import { SortableItem } from '../../fields/sortable-item'
|
|
33
33
|
import { useFormContext } from '../../forms/form-context'
|
|
34
|
+
import { hasExistingIdTargets } from '../../forms/nested-path'
|
|
35
|
+
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
|
|
34
36
|
import styles from './blocks-field.module.css'
|
|
35
37
|
|
|
36
38
|
// ---------------------------------------------------------------------------
|
|
@@ -54,7 +56,8 @@ export const BlocksField = ({
|
|
|
54
56
|
*/
|
|
55
57
|
contentLocale?: string
|
|
56
58
|
}) => {
|
|
57
|
-
const { appendPatch, getFieldValue, getFieldValues, setFieldStore } =
|
|
59
|
+
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
|
|
60
|
+
useFormContext()
|
|
58
61
|
const { t } = useTranslation('byline-admin')
|
|
59
62
|
const [items, setItems] = useState<{ id: string; data: any }[]>([])
|
|
60
63
|
const [showAddBlockModal, setShowAddBlockModal] = useState(false)
|
|
@@ -118,27 +121,20 @@ export const BlocksField = ({
|
|
|
118
121
|
moveFromIndex: number
|
|
119
122
|
moveToIndex: number
|
|
120
123
|
}) => {
|
|
121
|
-
setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
|
|
122
124
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
125
|
+
if (!Array.isArray(currentArray)) return
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
appendPatch({
|
|
136
|
-
kind: 'array.move',
|
|
137
|
-
path: path,
|
|
138
|
-
itemId,
|
|
139
|
-
toIndex: clampedTo,
|
|
140
|
-
})
|
|
141
|
-
}
|
|
127
|
+
const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
|
|
128
|
+
if (move == null) return
|
|
129
|
+
|
|
130
|
+
setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
|
|
131
|
+
setFieldStore(path, move.items)
|
|
132
|
+
appendPatch({
|
|
133
|
+
kind: 'array.move',
|
|
134
|
+
path,
|
|
135
|
+
itemId: move.itemId,
|
|
136
|
+
toIndex: move.toIndex,
|
|
137
|
+
})
|
|
142
138
|
}
|
|
143
139
|
|
|
144
140
|
const handleAddItem = async (forcedVariantName?: string, atIndex?: number) => {
|
|
@@ -163,6 +159,8 @@ export const BlocksField = ({
|
|
|
163
159
|
newItem[f.name] = await defaultScalarForField(f, getFieldValues)
|
|
164
160
|
}
|
|
165
161
|
|
|
162
|
+
if (!hasExistingIdTargets(getFieldValues(), path)) return
|
|
163
|
+
|
|
166
164
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
167
165
|
const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
|
|
168
166
|
|
|
@@ -190,12 +188,11 @@ export const BlocksField = ({
|
|
|
190
188
|
if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
|
|
191
189
|
|
|
192
190
|
const item = currentArray[index]
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
? String((item as { _id: string })._id)
|
|
196
|
-
: String(index)
|
|
191
|
+
const itemPath = repeatingItemPath(path, item, index)
|
|
192
|
+
const itemId = repeatingItemId(item) ?? String(index)
|
|
197
193
|
|
|
198
194
|
setItems((prev) => prev.filter((_, i) => i !== index))
|
|
195
|
+
removePendingUploadsUnder(itemPath)
|
|
199
196
|
|
|
200
197
|
appendPatch({
|
|
201
198
|
kind: 'array.remove',
|
|
@@ -219,7 +216,7 @@ export const BlocksField = ({
|
|
|
219
216
|
|
|
220
217
|
const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
|
|
221
218
|
const item = itemWrapper.data
|
|
222
|
-
const arrayElementPath =
|
|
219
|
+
const arrayElementPath = repeatingItemPath(path, item, index)
|
|
223
220
|
|
|
224
221
|
if (!item || typeof item !== 'object' || typeof item._type !== 'string') return null
|
|
225
222
|
|
|
@@ -233,9 +230,9 @@ export const BlocksField = ({
|
|
|
233
230
|
// Render the block's children directly with arrayElementPath as the
|
|
234
231
|
// path (not basePath). FieldRenderer would append the group name
|
|
235
232
|
// (e.g. "richTextBlock") producing paths like
|
|
236
|
-
// "content[
|
|
233
|
+
// "content[id=...].richTextBlock.constrainedWidth", but the flat block
|
|
237
234
|
// shape stores fields directly on the item so the correct path is
|
|
238
|
-
// "content[
|
|
235
|
+
// "content[id=...].constrainedWidth".
|
|
239
236
|
const body = (
|
|
240
237
|
<GroupField
|
|
241
238
|
key={subField.blockType}
|
|
@@ -24,7 +24,7 @@ const CodeEditor = React.lazy(() => import('./code-editor'))
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Resolve the form-store path of a sibling field (same group/block/array
|
|
27
|
-
* item scope). `content[
|
|
27
|
+
* item scope). `content[id=x].code` + `language` → `content[id=x].language`;
|
|
28
28
|
* a top-level `code` + `language` → `language`.
|
|
29
29
|
*/
|
|
30
30
|
const siblingFieldPath = (fieldPath: string, siblingName: string): string => {
|
|
@@ -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)
|
|
@@ -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 {
|
|
@@ -130,8 +135,9 @@ interface FormContextType {
|
|
|
130
135
|
subscribeErrors: (listener: ErrorsListener) => () => void
|
|
131
136
|
subscribeMeta: (listener: MetaListener) => () => void
|
|
132
137
|
// Pending uploads (deferred until save)
|
|
133
|
-
addPendingUpload: (fieldPath: string, upload: PendingUpload) =>
|
|
138
|
+
addPendingUpload: (fieldPath: string, upload: PendingUpload) => boolean
|
|
134
139
|
removePendingUpload: (fieldPath: string) => void
|
|
140
|
+
removePendingUploadsUnder: (itemPath: string) => void
|
|
135
141
|
getPendingUploads: () => Map<string, PendingUpload>
|
|
136
142
|
hasPendingUploads: () => boolean
|
|
137
143
|
clearPendingUploads: () => void
|
|
@@ -284,13 +290,14 @@ export const FormProvider = ({
|
|
|
284
290
|
const newFieldValues = { ...fieldValues.current }
|
|
285
291
|
|
|
286
292
|
// Keep nested path values up to date for generic usage and patches.
|
|
287
|
-
setNestedValue(newFieldValues, name, value)
|
|
293
|
+
if (!setNestedValue(newFieldValues, name, value)) return false
|
|
288
294
|
|
|
289
295
|
fieldValues.current = newFieldValues
|
|
290
296
|
dirtyFields.current.add(name)
|
|
291
297
|
|
|
292
298
|
notifyFieldListeners(name, value)
|
|
293
299
|
notifyMetaListeners()
|
|
300
|
+
return true
|
|
294
301
|
},
|
|
295
302
|
[notifyFieldListeners, notifyMetaListeners]
|
|
296
303
|
)
|
|
@@ -304,7 +311,7 @@ export const FormProvider = ({
|
|
|
304
311
|
|
|
305
312
|
const setFieldValue = useCallback(
|
|
306
313
|
(name: string, value: any) => {
|
|
307
|
-
updateFieldStoreInternal(name, value)
|
|
314
|
+
if (!updateFieldStoreInternal(name, value)) return
|
|
308
315
|
|
|
309
316
|
const patch: FieldSetPatch = {
|
|
310
317
|
kind: 'field.set',
|
|
@@ -421,6 +428,14 @@ export const FormProvider = ({
|
|
|
421
428
|
|
|
422
429
|
const addPendingUpload = useCallback(
|
|
423
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
|
+
|
|
424
439
|
// If there's an existing pending upload for this path, revoke its blob URL
|
|
425
440
|
const existing = pendingUploadsRef.current.get(fieldPath)
|
|
426
441
|
if (existing) {
|
|
@@ -429,6 +444,7 @@ export const FormProvider = ({
|
|
|
429
444
|
pendingUploadsRef.current.set(fieldPath, upload)
|
|
430
445
|
dirtyFields.current.add(fieldPath)
|
|
431
446
|
notifyMetaListeners()
|
|
447
|
+
return true
|
|
432
448
|
},
|
|
433
449
|
[notifyMetaListeners]
|
|
434
450
|
)
|
|
@@ -445,6 +461,16 @@ export const FormProvider = ({
|
|
|
445
461
|
[notifyMetaListeners]
|
|
446
462
|
)
|
|
447
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
|
+
|
|
448
474
|
const getPendingUploads = useCallback(() => {
|
|
449
475
|
return new Map(pendingUploadsRef.current)
|
|
450
476
|
}, [])
|
|
@@ -708,6 +734,7 @@ export const FormProvider = ({
|
|
|
708
734
|
subscribeMeta,
|
|
709
735
|
addPendingUpload,
|
|
710
736
|
removePendingUpload,
|
|
737
|
+
removePendingUploadsUnder,
|
|
711
738
|
getPendingUploads,
|
|
712
739
|
hasPendingUploads,
|
|
713
740
|
clearPendingUploads,
|
|
@@ -551,6 +551,8 @@ const FormContent = ({
|
|
|
551
551
|
noValidate
|
|
552
552
|
onSubmit={handleSubmit}
|
|
553
553
|
className={cx('byline-form', styles.form)}
|
|
554
|
+
inert={isUploading ? true : undefined}
|
|
555
|
+
aria-busy={isUploading}
|
|
554
556
|
>
|
|
555
557
|
<div className={cx('byline-form-heading-row', styles['heading-row'])}>
|
|
556
558
|
<h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
|
|
@@ -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
|
}
|