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:
@@ -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}', {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user