@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.
Files changed (47) hide show
  1. package/dist/fields/array/array-field.d.ts +1 -8
  2. package/dist/fields/array/array-field.js +21 -27
  3. package/dist/fields/blocks/blocks-field.js +19 -17
  4. package/dist/fields/field-renderer.d.ts +1 -3
  5. package/dist/fields/field-renderer.js +3 -7
  6. package/dist/fields/file/file-field.d.ts +1 -3
  7. package/dist/fields/file/file-field.js +2 -2
  8. package/dist/fields/file/file-upload-field.js +2 -2
  9. package/dist/fields/group/group-field.d.ts +1 -7
  10. package/dist/fields/group/group-field.js +1 -2
  11. package/dist/fields/image/image-field.d.ts +1 -3
  12. package/dist/fields/image/image-field.js +2 -2
  13. package/dist/fields/image/image-upload-field.js +12 -3
  14. package/dist/forms/__probe-nested.test.node.d.ts +1 -0
  15. package/dist/forms/__probe-two.test.node.d.ts +1 -0
  16. package/dist/forms/form-context.d.ts +22 -2
  17. package/dist/forms/form-context.js +19 -4
  18. package/dist/forms/form-renderer.js +3 -1
  19. package/dist/forms/nested-path.d.ts +5 -1
  20. package/dist/forms/nested-path.js +84 -15
  21. package/dist/forms/pending-uploads.d.ts +6 -0
  22. package/dist/forms/pending-uploads.js +11 -0
  23. package/dist/forms/pending-uploads.test.node.d.ts +1 -0
  24. package/dist/forms/repeating-items.d.ts +16 -0
  25. package/dist/forms/repeating-items.js +28 -0
  26. package/dist/forms/repeating-items.test.node.d.ts +1 -0
  27. package/dist/forms/upload-executor.js +50 -29
  28. package/package.json +5 -5
  29. package/src/fields/array/array-field.tsx +28 -48
  30. package/src/fields/blocks/blocks-field.tsx +24 -27
  31. package/src/fields/code/code-field.tsx +1 -1
  32. package/src/fields/field-renderer.tsx +0 -7
  33. package/src/fields/file/file-field.tsx +4 -4
  34. package/src/fields/file/file-upload-field.tsx +9 -5
  35. package/src/fields/group/group-field.tsx +0 -8
  36. package/src/fields/image/image-field.tsx +4 -4
  37. package/src/fields/image/image-upload-field.tsx +24 -6
  38. package/src/forms/form-context.tsx +52 -4
  39. package/src/forms/form-renderer.tsx +3 -1
  40. package/src/forms/nested-path.test.node.ts +50 -1
  41. package/src/forms/nested-path.ts +106 -18
  42. package/src/forms/pending-uploads.test.node.ts +23 -0
  43. package/src/forms/pending-uploads.ts +22 -0
  44. package/src/forms/repeating-items.test.node.ts +36 -0
  45. package/src/forms/repeating-items.ts +48 -0
  46. package/src/forms/upload-executor.test.node.ts +248 -0
  47. 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
- const { removePendingUpload, documentId } = useFormContext()
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
- addPendingUpload(fieldPath, {
75
- file,
76
- previewUrl,
77
- collectionPath,
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
- const { removePendingUpload, documentId } = useFormContext()
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
- addPendingUpload(fieldPath, {
104
- file,
105
- previewUrl,
106
- collectionPath,
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 { get as getNestedValue, set as setNestedValue } from './nested-path'
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) => void
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
+ })
@@ -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[2].nested[1].field`.
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
- const isIndexKey = (key: string): boolean => /^(?:0|[1-9]\d*)$/.test(key)
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 key of toPath(path)) {
46
+ for (const segment of parsed.segments) {
35
47
  if (current == null) return undefined as T
36
- current = current[key]
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
- export function set<T extends object>(object: T, path: string, value: unknown): T {
42
- if (object == null) return object
43
- const keys = toPath(path)
44
- if (keys.length === 0) return object
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 < keys.length - 1; i++) {
48
- // Bounded by the loop condition, so these indexed reads are always defined.
49
- const key = keys[i] as string
50
- const nextKey = keys[i + 1] as string
51
- const existing = current[key]
52
- if (existing == null || typeof existing !== 'object') {
53
- // Create the container the next segment needs: array for an index, else object.
54
- current[key] = isIndexKey(nextKey) ? [] : {}
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
- current = current[key]
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
- current[keys[keys.length - 1] as string] = value
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
+ })