@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.
Files changed (33) hide show
  1. package/dist/fields/array/array-field.js +20 -24
  2. package/dist/fields/blocks/blocks-field.js +19 -17
  3. package/dist/fields/file/file-upload-field.js +2 -2
  4. package/dist/fields/image/image-upload-field.js +12 -3
  5. package/dist/forms/__probe-nested.test.node.d.ts +1 -0
  6. package/dist/forms/__probe-two.test.node.d.ts +1 -0
  7. package/dist/forms/form-context.d.ts +2 -1
  8. package/dist/forms/form-context.js +17 -3
  9. package/dist/forms/form-renderer.js +2 -0
  10. package/dist/forms/nested-path.d.ts +5 -1
  11. package/dist/forms/nested-path.js +84 -15
  12. package/dist/forms/pending-uploads.d.ts +6 -0
  13. package/dist/forms/pending-uploads.js +11 -0
  14. package/dist/forms/pending-uploads.test.node.d.ts +1 -0
  15. package/dist/forms/repeating-items.d.ts +16 -0
  16. package/dist/forms/repeating-items.js +28 -0
  17. package/dist/forms/repeating-items.test.node.d.ts +1 -0
  18. package/package.json +5 -5
  19. package/src/fields/array/array-field.tsx +28 -38
  20. package/src/fields/blocks/blocks-field.tsx +24 -27
  21. package/src/fields/code/code-field.tsx +1 -1
  22. package/src/fields/file/file-upload-field.tsx +9 -5
  23. package/src/fields/image/image-upload-field.tsx +24 -6
  24. package/src/forms/form-context.tsx +31 -4
  25. package/src/forms/form-renderer.tsx +2 -0
  26. package/src/forms/nested-path.test.node.ts +50 -1
  27. package/src/forms/nested-path.ts +106 -18
  28. package/src/forms/pending-uploads.test.node.ts +23 -0
  29. package/src/forms/pending-uploads.ts +22 -0
  30. package/src/forms/repeating-items.test.node.ts +36 -0
  31. package/src/forms/repeating-items.ts +48 -0
  32. package/src/forms/upload-executor.test.node.ts +64 -3
  33. package/src/forms/upload-executor.ts +4 -1
@@ -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
+ })
@@ -0,0 +1,48 @@
1
+ /** Return a canonical identity that is safe in an instance-path selector. */
2
+ export function repeatingItemId(item: unknown): string | undefined {
3
+ if (item == null || typeof item !== 'object' || !('_id' in item)) return undefined
4
+ const id = (item as { _id: unknown })._id
5
+ return typeof id === 'string' && id !== '' && !/[.[\]]/.test(id) ? id : undefined
6
+ }
7
+
8
+ /**
9
+ * Address an array/block item by stable identity when its id is path-safe.
10
+ * Positional fallback is retained for noncanonical create defaults and legacy
11
+ * adapter data that does not carry storage identity yet.
12
+ */
13
+ export function repeatingItemPath(parentPath: string, item: unknown, index: number): string {
14
+ const id = repeatingItemId(item)
15
+ return id != null ? `${parentPath}[id=${id}]` : `${parentPath}[${index}]`
16
+ }
17
+
18
+ export interface RepeatingItemMove<T> {
19
+ items: T[]
20
+ itemId: string
21
+ fromIndex: number
22
+ toIndex: number
23
+ }
24
+
25
+ /** Build one synchronized form-store move and its matching patch identity. */
26
+ export function moveRepeatingItems<T>(
27
+ items: readonly T[],
28
+ moveFromIndex: number,
29
+ moveToIndex: number
30
+ ): RepeatingItemMove<T> | null {
31
+ if (items.length === 0) return null
32
+
33
+ const fromIndex = Math.max(0, Math.min(moveFromIndex, items.length - 1))
34
+ const toIndex = Math.max(0, Math.min(moveToIndex, items.length - 1))
35
+ if (fromIndex === toIndex) return null
36
+
37
+ const source = items[fromIndex]
38
+ const moved = [...items]
39
+ const [item] = moved.splice(fromIndex, 1)
40
+ moved.splice(toIndex, 0, item as T)
41
+
42
+ return {
43
+ items: moved,
44
+ itemId: repeatingItemId(source) ?? String(fromIndex),
45
+ fromIndex,
46
+ toIndex,
47
+ }
48
+ }
@@ -99,10 +99,20 @@ const blocksFields: Field[] = [
99
99
  name: 'poster',
100
100
  label: 'Poster',
101
101
  type: 'image',
102
- upload: { context: ['/title'] },
102
+ upload: { context: ['../caption', '/title'] },
103
103
  },
104
104
  ],
105
105
  },
106
+ { name: 'caption', label: 'Caption', type: 'text' },
107
+ {
108
+ // Declared directly on the block, unlike `poster` above. This is
109
+ // the only depth at which `..` must leave the block entirely, so
110
+ // it is the only depth that can detect a scope miscount.
111
+ name: 'blockFile',
112
+ label: 'Block file',
113
+ type: 'file',
114
+ upload: { context: ['caption', '../title'] },
115
+ },
106
116
  ],
107
117
  },
108
118
  ],
@@ -113,8 +123,13 @@ const blocksFields: Field[] = [
113
123
  const blocksFormValues = () => ({
114
124
  title: 'Hello',
115
125
  content: [
116
- { _type: 'photoBlock', _id: 'blk-photo', gallery: [{}] },
117
- { _type: 'videoBlock', _id: 'blk-video', gallery: [{}] },
126
+ { _type: 'photoBlock', _id: 'blk-photo', gallery: [{ _id: 'gallery-photo' }] },
127
+ {
128
+ _type: 'videoBlock',
129
+ _id: 'blk-video',
130
+ gallery: [{ _id: 'gallery-video' }],
131
+ caption: 'Video caption',
132
+ },
118
133
  ],
119
134
  })
120
135
 
@@ -295,6 +310,52 @@ describe('executeUploads — upload fields inside blocks', () => {
295
310
  expect(bodies[0]?.get('title')).toBe('Hello')
296
311
  })
297
312
 
313
+ it('resolves sibling context through stable outer and inner item paths', async () => {
314
+ const { fn, bodies } = captureUploadField()
315
+ const uploads = new Map([
316
+ ['content[id=blk-video].gallery[id=gallery-video].poster', imageUpload()],
317
+ ])
318
+
319
+ await executeUploads(uploads, fn, {
320
+ fields: blocksFields,
321
+ getFormValues: blocksFormValues,
322
+ })
323
+
324
+ expect(bodies[0]?.get('caption')).toBe('Video caption')
325
+ expect(bodies[0]?.get('title')).toBe('Hello')
326
+ })
327
+
328
+ it('climbs out of the block to the document root for an upload declared on the block', async () => {
329
+ // The scope boundary, and the one case that can catch a miscount.
330
+ //
331
+ // `poster` sits inside `gallery[]`, so its `../caption` climbs from the
332
+ // array item to the *block item* — a hop that stays inside the block.
333
+ // `blockFile` sits on the block itself, so `../title` has to leave the
334
+ // block and land at the document root.
335
+ //
336
+ // Those two behave differently under a path that carries a segment which
337
+ // addresses nothing. An abandoned experiment qualified instance paths with
338
+ // the block type (`content[1].videoBlock.blockFile`), and because
339
+ // `resolveContextPath` counts every dotted segment as one scope,`..`
340
+ // stopped one level short: `../title` resolved to `content[1].title`
341
+ // inside the block rather than the root, and arrived empty. The
342
+ // inside-the-block case absorbed the extra segment and kept passing —
343
+ // which is exactly why it cannot stand in for this one.
344
+ const { fn, bodies } = captureUploadField()
345
+ const uploads = new Map([['content[1].blockFile', pendingUpload()]])
346
+
347
+ await executeUploads(uploads, fn, {
348
+ fields: blocksFields,
349
+ getFormValues: blocksFormValues,
350
+ })
351
+
352
+ // Root-level `title`, not the block's own `caption` scope.
353
+ expect(bodies[0]?.get('title')).toBe('Hello')
354
+ // The sibling still resolves, so a failure above is the climb specifically
355
+ // and not block resolution having gone wrong generally.
356
+ expect(bodies[0]?.get('caption')).toBe('Video caption')
357
+ })
358
+
298
359
  it('still resolves when the addressed block item is missing from form state', async () => {
299
360
  // Form state can lag a pending upload. The item is what supplies `_type`,
300
361
  // so its absence drops us to the unique-match fallback — but the *field*
@@ -216,7 +216,10 @@ function resolveContextPath(fieldPath: string, contextPath: string): string | un
216
216
  }
217
217
 
218
218
  // Scope = the upload field's containing segments (dot-split keeps array
219
- // indices attached to their segment: `files[2]` stays one hop).
219
+ // selectors attached to their segment: `files[id=x]` stays one hop).
220
+ // This relies on every dotted form-path segment being a real data scope. If
221
+ // the grammar ever adds descriptive/non-navigating segments, classify and
222
+ // remove them before counting `..` hops rather than changing parent scope.
220
223
  const scope = fieldPath.split('.')
221
224
  scope.pop() // drop the upload field's own leaf segment
222
225