@byline/admin 4.4.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.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
|
@@ -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
|
-
{
|
|
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
|
-
//
|
|
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
|
|