fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -37,9 +37,12 @@ export interface UploadHandle {
export type UploadProgress = (loaded: number, total: number) => void
/** Everything a fileupload control does with its files. */
/** Client upload id sent as X-Upload-Id so a retry returns the stored file. */
export const UPLOAD_ID_HEADER = 'X-Upload-Id'
export interface FileRoutes {
list(): Promise<FileCallResult<FileItem[]>>
upload(file: File, onProgress?: UploadProgress): UploadHandle
upload(file: File, onProgress?: UploadProgress, uploadId?: string): UploadHandle
update(file: number, body: AdminFileCaptionRequest): Promise<FileCallResult<FileItem>>
remove(file: number): Promise<FileCallResult<FileMutationResult>>
reorder(ids: number[]): Promise<FileCallResult<FileItem[]>>
@@ -137,6 +140,7 @@ export function uploadWithProgress(
file: File,
headers: Record<string, string>,
onProgress?: UploadProgress,
uploadId?: string,
): UploadHandle {
let current: XMLHttpRequest | null = null
let aborted = false
@@ -151,6 +155,9 @@ export function uploadWithProgress(
for (const [name, value] of Object.entries(headers)) {
request.setRequestHeader(name, value)
}
if (uploadId) {
request.setRequestHeader(UPLOAD_ID_HEADER, uploadId)
}
request.upload.onprogress = (event: ProgressEvent) => {
if (event.lengthComputable) {
onProgress?.(event.loaded, event.total)
@@ -208,7 +215,7 @@ export function parentFileRoutes(
const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}`
return {
list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress),
upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
@@ -273,7 +280,7 @@ export function childFileRoutes(
params: { path, header },
}),
),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress),
upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {

View File

@@ -287,7 +287,13 @@ export function boundValue(
return day
}
const wall = new CalendarDateTime(day.year, day.month, day.day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0)
return ignoreTimezone ? wall : toZoned(wall, getLocalTimeZone())
if (ignoreTimezone) {
return wall
}
// The server checks the saved instant's UTC calendar date. Build the
// bound as a UTC instant so a local midnight just after the UTC day
// change is refused, and a local evening still on that UTC day is allowed.
return toTimeZone(toZoned(wall, 'UTC'), getLocalTimeZone())
}
/**

View File

@@ -55,6 +55,8 @@ interface Item {
file: FileItem | null
source: File | null
handle: UploadHandle | null
/** Stable client id sent on every attempt of this queued file. */
uploadId: string
}
let nextUid = 1
@@ -78,6 +80,9 @@ const transient: string[] = []
const routes: FileRoutes | null = session ? session.routes(props.field.name) : null
let reorderTimer: ReturnType<typeof setTimeout> | null = null
let orderBefore: number[] | null = null
let orderInFlight = false
let pendingOrder: number[] | null = null
let orderGen = 0
let unmounted = false
const readOnly = computed(() => {
@@ -240,9 +245,14 @@ function itemFor(file: FileItem): Item {
file,
source: null,
handle: null,
uploadId: '',
}) as Item
}
function newUploadId(): string {
return globalThis.crypto?.randomUUID?.() ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function localItem(source: File, error: string): Item {
return reactive<Item>({
uid: nextUid++,
@@ -255,6 +265,7 @@ function localItem(source: File, error: string): Item {
file: null,
source: markRaw(source),
handle: null,
uploadId: newUploadId(),
}) as Item
}
@@ -380,48 +391,86 @@ async function upload(item: Item): Promise<void> {
item.progress = 0
item.error = ''
item.retryable = false
const handle = routes.upload(item.source, (loaded, total) => {
item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
})
item.handle = markRaw(handle)
const result = await handle.promise
item.handle = null
const kept = !!find(item.uid)
if (result.ok) {
if (!kept || unmounted) {
// Cancelled after the server stored it: drop the pending upload.
void routes.remove(result.item.id)
} else {
item.state = 'done'
item.progress = 100
item.file = result.item
item.name = result.item.file_name
item.size = result.item.file_size
session?.markDirty()
}
} else if (kept && result.reason !== 'aborted') {
item.state = 'failed'
switch (result.reason) {
case 'too_large':
item.error = maxSizeLabel.value
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value })
: result.message || t('backend::lang.fileupload.upload_failed')
break
case 'invalid':
item.error = result.message || t('backend::lang.fileupload.upload_failed')
break
case 'network':
const known = new Set(items.value.filter((entry) => entry.file).map((entry) => entry.file!.id))
const release = session?.beginUpload()
try {
const handle = routes.upload(
item.source,
(loaded, total) => {
item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
},
item.uploadId || undefined,
)
item.handle = markRaw(handle)
const result = await handle.promise
item.handle = null
const kept = !!find(item.uid)
if (result.ok) {
if (!kept || unmounted) {
// Cancelled after the server stored it: drop the pending upload.
void routes.remove(result.item.id)
} else {
item.state = 'done'
item.progress = 100
item.file = result.item
item.name = result.item.file_name
item.size = result.item.file_size
session?.markDirty()
}
} else if (result.reason === 'aborted' || result.reason === 'network') {
const extras = await lostUploads(known)
if (result.reason === 'aborted') {
for (const extra of extras) {
void routes.remove(extra.id)
}
items.value = items.value.filter((entry) => entry.uid !== item.uid)
forgetThumb(item.uid)
} else if (kept && extras[0] && extras.length === 1) {
item.state = 'done'
item.progress = 100
item.file = extras[0]
item.name = extras[0].file_name
item.size = extras[0].file_size
session?.markDirty()
} else {
item.state = 'failed'
item.error = t('backend::lang.fileupload.upload_failed')
item.retryable = true
break
default:
item.error = result.message || t('backend::lang.fileupload.upload_failed')
item.retryable = result.status >= 500
}
} else if (kept) {
item.state = 'failed'
switch (result.reason) {
case 'too_large':
item.error = maxSizeLabel.value
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value })
: result.message || t('backend::lang.fileupload.upload_failed')
break
case 'invalid':
item.error = result.message || t('backend::lang.fileupload.upload_failed')
break
default:
item.error = result.message || t('backend::lang.fileupload.upload_failed')
item.retryable = result.status >= 500
}
}
} finally {
release?.()
if (!unmounted) {
pump()
}
}
if (!unmounted) {
pump()
}
/** Pending files that appeared after this upload started (response lost). */
async function lostUploads(known: Set<number>): Promise<FileItem[]> {
if (!routes) {
return []
}
const listed = await routes.list()
if (!listed.data) {
return []
}
return listed.data.filter((file) => file.pending && !known.has(file.id))
}
function retry(item: Item): void {
@@ -488,19 +537,40 @@ function scheduleReorder(): void {
}, REORDER_DEBOUNCE)
}
function currentFileIds(): number[] {
return items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id)
}
async function sendOrder(): Promise<void> {
const before = orderBefore
orderBefore = null
if (!routes) {
return
}
const ids = items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id)
const result = await routes.reorder(ids)
if (!result.data && !unmounted) {
if (before) {
restore(before)
const ids = currentFileIds()
if (orderInFlight) {
pendingOrder = ids
return
}
const before = orderBefore
orderBefore = null
const gen = ++orderGen
orderInFlight = true
try {
const result = await routes.reorder(ids)
if (pendingOrder) {
return
}
if (!result.data && !unmounted && gen === orderGen) {
if (before) {
restore(before)
}
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
}
} finally {
orderInFlight = false
if (pendingOrder) {
pendingOrder = null
void sendOrder()
}
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
}
}

View File

@@ -3,7 +3,7 @@
// writes action results back through patch, which marks the form dirty and
// clears that field's errors exactly like typing into it. A form without a
// provider (settings pages) gets the defaults of the injecting control.
import type { InjectionKey, Ref } from 'vue'
import { ref, type InjectionKey, type Ref } from 'vue'
import type { FileRoutes } from '../../api/files'
import type { AdminRecord } from '../../api/types'
@@ -46,6 +46,35 @@ export interface FormSession {
pendingChanges: Readonly<Ref<number>>
/** Goes up after every successful save, so fields can reload their files. */
revision: Readonly<Ref<number>>
/**
* Registers an in-flight upload. Call the returned function in `finally`
* when the request reaches a terminal state. The form will not submit
* while any upload is still open.
*/
beginUpload(): () => void
/** How many uploads have not yet reached a terminal state. */
activeUploads: Readonly<Ref<number>>
}
/** Counter and register/release pair used by record and child forms. */
export function createUploadGate(): { activeUploads: Ref<number>; beginUpload: () => () => void } {
const activeUploads = ref(0)
return {
activeUploads,
beginUpload() {
activeUploads.value++
let released = false
return () => {
if (released) {
return
}
released = true
if (activeUploads.value > 0) {
activeUploads.value--
}
}
},
}
}
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')

View File

@@ -17,7 +17,7 @@ import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/s
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import FormTabs from '../form/FormTabs.vue'
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext'
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
import {
DEFAULT_TAB,
contextAllows,
@@ -79,6 +79,7 @@ const activeTab = ref(DEFAULT_TAB)
const childKey = ref(newSessionKey())
const pendingChanges = ref(0)
const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
const body = ref<HTMLElement | null>(null)
const confirm = useConfirm()
let generation = 0
@@ -148,7 +149,9 @@ const dirty = computed(
!preview.value &&
!loading.value &&
!failed.value &&
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
)
function headers() {
@@ -189,6 +192,8 @@ const session: FormSession = {
},
pendingChanges: readonly(pendingChanges),
revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
}
provide(FORM_SESSION, session)
provide(FORM_VALUES, computed(() => values.value))
@@ -319,7 +324,7 @@ async function showErrors(details: Record<string, unknown> | undefined): Promise
}
async function onSubmit(): Promise<void> {
if (busy.value || preview.value || loading.value || failed.value) {
if (busy.value || preview.value || loading.value || failed.value || activeUploads.value > 0) {
return
}
busy.value = true
@@ -506,7 +511,7 @@ function onCloseAutoFocus(event: Event): void {
v-if="!failed"
variant="primary"
data-action="save-child"
:disabled="busy || loading"
:disabled="busy || loading || activeUploads > 0"
@click="onSubmit"
>
{{ busy ? t('backend::lang.form.saving') : submitLabel }}

View File

@@ -13,7 +13,7 @@ import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
@@ -79,6 +79,7 @@ const sessionHeader = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': str
const pendingChanges = ref(0)
// Bumped after each successful save, so file fields reload their lists.
const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
// A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
@@ -151,7 +152,9 @@ const subtitle = computed(() =>
const dirty = computed(
() =>
!loading.value &&
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
)
function adopt(record: RecordEnvelope | undefined): void {
@@ -211,6 +214,8 @@ provide(FORM_SESSION, {
},
pendingChanges: readonly(pendingChanges),
revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
})
/** 422: messages under fields, the first invalid field (schema order) focused. */
@@ -236,7 +241,7 @@ function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string
/** Saves the record; returns the saved envelope, or null after an error. */
async function save(): Promise<RecordEnvelope | null> {
if (busy.value || !schema.value) {
if (busy.value || !schema.value || activeUploads.value > 0) {
return null
}
busy.value = true
@@ -420,10 +425,10 @@ void load()
</Button>
<div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema" @click="onSaveAndClose">
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
{{ t('backend::lang.form.save_and_close') }}
</Button>
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave">
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSave">
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</div>

View File

@@ -196,7 +196,25 @@ describe('dayBounds and boundValue', () => {
expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59')
const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime
expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([0, 0])
// UTC 2026-10-02 00:00 is 02:00 in Warsaw (CEST).
expect([zoned.hour, zoned.minute]).toEqual([2, 0])
const max = boundValue(day, 'datetime', false, true) as ZonedDateTime
expect([max.hour, max.minute, max.second]).toEqual([1, 59, 59])
expect(max.day).toBe(3)
})
it('uses UTC-day instants so a local midnight just after the UTC date change is out of range', () => {
const min = boundValue(new CalendarDate(2000, 1, 1), 'datetime', false, false) as ZonedDateTime
const local = parseFieldValue('datetime', '2000-01-01T00:30:00+01:00') as ZonedDateTime
expect(local.compare(min)).toBeLessThan(0)
const allowed = parseFieldValue('datetime', '2000-01-01T01:30:00+01:00') as ZonedDateTime
expect(allowed.compare(min)).toBeGreaterThanOrEqual(0)
setLocalTimeZone('America/New_York')
const max = boundValue(new CalendarDate(2000, 1, 1), 'datetime', false, true) as ZonedDateTime
const evening = parseFieldValue('datetime', '2000-01-01T20:00:00-05:00') as ZonedDateTime
expect(evening.compare(max)).toBeGreaterThan(0)
const afternoon = parseFieldValue('datetime', '2000-01-01T15:00:00-05:00') as ZonedDateTime
expect(afternoon.compare(max)).toBeLessThanOrEqual(0)
})
it('starts a fresh picker at today in its value type', () => {

View File

@@ -9,13 +9,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import FileuploadField from '../../src/components/form/fields/FileuploadField.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { FORM_SESSION, createUploadGate, type FormSession } from '../../src/components/form/formContext'
import { UPLOAD_ID_HEADER } from '../../src/api/files'
import { parentFileRoutes } from '../../src/api/files'
import type { FileItem, FormField } from '../../src/api/types'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { deferredFilesFixture } from '../fixtures/typed'
import { API, keydown, mockApi, requestsTo, resetState, wait, type Route } from '../helpers'
import { API, keydown, mockApi, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
const KEY = 'k'.repeat(43)
@@ -90,7 +91,7 @@ function item(id: number, name: string, extra: Partial<FileItem> = {}): FileItem
}
}
function listOf(files: FileItem[]): Route {
function listOf(files: FileItem[]): Reply {
return { body: { data: files, meta: {} } }
}
@@ -105,6 +106,7 @@ async function setup(options: Setup = {}) {
const field = options.field ?? photos()
const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) })
const markDirty = vi.fn()
const { activeUploads, beginUpload } = createUploadGate()
const session: FormSession = {
key: KEY,
recordId,
@@ -112,6 +114,8 @@ async function setup(options: Setup = {}) {
markDirty,
pendingChanges: ref(0),
revision: ref(0),
beginUpload,
activeUploads,
}
const wrapper = mount(FileuploadField, {
props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' },
@@ -119,7 +123,7 @@ async function setup(options: Setup = {}) {
attachTo: document.body,
})
await flushPromises()
return { wrapper, calls, markDirty }
return { wrapper, calls, markDirty, activeUploads }
}
async function choose(wrapper: VueWrapper, files: File[]): Promise<void> {
@@ -193,12 +197,14 @@ describe('dropzone and client checks', () => {
describe('upload states', () => {
it('goes queued, uploading with progress, done; a server failure can be retried', async () => {
const { wrapper, markDirty } = await setup()
const { wrapper, markDirty, activeUploads } = await setup()
await choose(wrapper, [png('a.png'), png('b.png')])
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
expect(activeUploads.value).toBe(1)
expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued')
const first = FakeXHR.sent[0]!
expect(first.headers[SESSION_HEADER]).toBe(KEY)
expect(first.headers[UPLOAD_ID_HEADER]).toMatch(/^[A-Za-z0-9_-]{8,}$/)
first.progress(30, 60)
await flushPromises()
expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true)
@@ -220,6 +226,60 @@ describe('upload states', () => {
await flushPromises()
expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2)
expect(markDirty).toHaveBeenCalledTimes(2)
expect(activeUploads.value).toBe(0)
})
it('keeps the same upload id on retry and holds the form upload gate until the XHR ends', async () => {
const { wrapper, activeUploads } = await setup()
await choose(wrapper, [png('a.png')])
expect(activeUploads.value).toBe(1)
const first = FakeXHR.sent[0]!
const id = first.headers[UPLOAD_ID_HEADER]
expect(id).toBeTruthy()
first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } })
await flushPromises()
expect(activeUploads.value).toBe(0)
await wrapper.find('[data-action="retry"]').trigger('click')
await flushPromises()
expect(FakeXHR.sent[1]!.headers[UPLOAD_ID_HEADER]).toBe(id)
FakeXHR.sent[1]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} })
await flushPromises()
expect(activeUploads.value).toBe(0)
})
it('deletes a server-side file when the upload is aborted after the store', async () => {
let listed = 0
const { wrapper, calls } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: () => {
listed++
return listed === 1 ? listOf([]) : listOf([item(99, 'lost.png', { pending: true })])
},
[`DELETE ${BASE}/7/files/photos/99`]: { body: { data: { removed: true, pending: true }, meta: {} } },
},
})
await choose(wrapper, [png('lost.png')])
FakeXHR.sent[0]!.abort()
await flushPromises()
expect(requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/99`)).toHaveLength(1)
expect(wrapper.find('[data-file-item]').exists()).toBe(false)
})
it('adopts a stored file when the response is lost and keeps the same upload id on retry', async () => {
let listed = 0
const { wrapper } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: () => {
listed++
return listed === 1 ? listOf([]) : listOf([item(77, 'net.png', { pending: true })])
},
},
})
await choose(wrapper, [png('net.png')])
FakeXHR.sent[0]!.onerror?.()
await flushPromises()
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
expect(wrapper.find('[data-file-item]').text()).toContain('net.png')
})
it('shows the Unsaved chip on pending files of a saved record only', async () => {
@@ -346,6 +406,42 @@ describe('keyboard reorder (backstop 4)', () => {
expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png'])
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' })
})
it('sends only the latest order when a second move happens while the first request is open', async () => {
const replies: Array<(ids: number[]) => Reply> = []
const seen: number[][] = []
const { wrapper, calls } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf(three),
[`POST ${BASE}/7/files/photos/reorder`]: async (request) => {
const body = (await request.clone().json()) as { ids: number[] }
seen.push(body.ids)
return await new Promise<Reply>((resolve) => {
replies.push((ids) => {
const ordered = ids.map((id) => three.find((file) => file.id === id)!)
const reply = { body: { data: ordered, meta: {} } }
resolve(reply)
return reply
})
})
},
},
})
keydown(wrapper.findAll('[data-handle]')[0]!.element, 'ArrowDown')
await flushPromises()
await wait(450)
await flushPromises()
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(1)
keydown(wrapper.findAll('[data-handle]')[1]!.element, 'ArrowDown')
await flushPromises()
await wait(450)
await flushPromises()
expect(replies).toHaveLength(1)
replies[0]!([42, 41, 43])
await flushPromises()
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(2)
expect(seen[1]).toEqual([42, 43, 41])
})
})
describe('caption modal', () => {

View File

@@ -259,6 +259,8 @@ describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-1
markDirty: vi.fn(),
pendingChanges: ref(0),
revision: ref(0),
beginUpload: () => () => {},
activeUploads: ref(0),
}
}

View File

@@ -156,6 +156,7 @@ describe('fileupload on a new record (tracer)', () => {
expect(new URL(upload.url, 'http://local').pathname).toBe(`${BASE}/0/files/photos`)
expect(upload.url).not.toContain('?')
expect(upload.headers[SESSION_HEADER]).toBe(key)
expect(upload.headers['X-Upload-Id']).toMatch(/^[A-Za-z0-9_-]{8,}$/)
expect(upload.headers['X-Requested-With']).toBe('XMLHttpRequest')
expect((upload.body!.get('file_data') as File).name).toBe('box.png')
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
@@ -199,6 +200,23 @@ describe('fileupload on a new record (tracer)', () => {
expect(wrapper.find('[data-file-item]').text()).toContain('box.png')
})
it('does not save while an upload is still in flight', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/create', routes())
await choose(wrapper, [png('box.png')])
expect(FakeXHR.sent).toHaveLength(1)
await wrapper.find('#field-name').setValue('Box')
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(0)
FakeXHR.sent[0]!.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
await flushPromises()
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
})
it('refuses a wrong type and an oversized file before sending and drops files past maxFiles', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets/create', routes())