feat(12.2-04): add the fileupload field with deferred uploads on the form session key
- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers - api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry - FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews - FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update - fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
This commit is contained in:
68
admin/tests/fixtures/deferred.form-schema.json
vendored
Normal file
68
admin/tests/fixtures/deferred.form-schema.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Gadget",
|
||||
"fields": [
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Name",
|
||||
"type": "text",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "photos",
|
||||
"label": "Photos",
|
||||
"type": "fileupload",
|
||||
"mode": "image",
|
||||
"multiple": true,
|
||||
"maxFiles": 3,
|
||||
"fileTypes": [
|
||||
"jpg",
|
||||
"png"
|
||||
],
|
||||
"maxFilesize": 2,
|
||||
"thumbOptions": {
|
||||
"mode": "crop"
|
||||
},
|
||||
"useCaption": true
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": {
|
||||
"other": "New gadget"
|
||||
},
|
||||
"update": {
|
||||
"other": "Editing gadget"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Gadget „:name” saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this gadget?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Gadget deleted"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": []
|
||||
},
|
||||
"redirects": {
|
||||
"create": {
|
||||
"redirect": "acme/demo/gadgets/update/:id",
|
||||
"redirectClose": "acme/demo/gadgets"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/demo/gadgets",
|
||||
"redirectClose": "acme/demo/gadgets"
|
||||
},
|
||||
"default": "acme/demo/gadgets"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -10,6 +10,7 @@ import settingsJson from './settings.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import extensionListSchemaJson from './extension.list-schema.json'
|
||||
import extensionPartialJson from './extension.partial.json'
|
||||
import deferredFormSchemaJson from './deferred.form-schema.json'
|
||||
import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
@@ -30,6 +31,8 @@ export const settingsFixture: {
|
||||
values: S['cabana.Envelope-cabana_SettingsResult']
|
||||
} = settingsJson
|
||||
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
|
||||
/** A gadget form with deferred-binding fields: fileupload (Phase 12.2). */
|
||||
export const deferredFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = deferredFormSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('field renderer registry (D-05)', () => {
|
||||
expect(rendererFor(type)).toBe(component)
|
||||
})
|
||||
|
||||
it.each(['colorpicker', 'fileupload', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
|
||||
it.each(['colorpicker', 'codeeditor', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
|
||||
expect(rendererFor(type)).toBe(UnsupportedField)
|
||||
expect(isRegistered(type)).toBe(false)
|
||||
})
|
||||
|
||||
212
admin/tests/smoke/deferred.smoke.test.ts
Normal file
212
admin/tests/smoke/deferred.smoke.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// Deferred binding end to end in the SPA (Phase 12.2, D-02, D-03): a new
|
||||
// record's form makes one session key, uploads go to record id 0 with that
|
||||
// key in a header, the form turns dirty, and the create save sends the same
|
||||
// key so the server can attach the files. The upload XMLHttpRequest is
|
||||
// replaced by a fake the test answers by hand.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { clearToasts } from '../../src/state/useToasts'
|
||||
import lang from '../fixtures/lang.json'
|
||||
import { deferredFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/gadgets`
|
||||
const KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
class FakeXHR {
|
||||
static sent: FakeXHR[] = []
|
||||
method = ''
|
||||
url = ''
|
||||
headers: Record<string, string> = {}
|
||||
body: FormData | null = null
|
||||
status = 0
|
||||
responseText = ''
|
||||
upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
ontimeout: (() => void) | null = null
|
||||
|
||||
open(method: string, url: string): void {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string): void {
|
||||
this.headers[name] = value
|
||||
}
|
||||
|
||||
send(body: FormData): void {
|
||||
this.body = body
|
||||
FakeXHR.sent.push(this)
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.onabort?.()
|
||||
}
|
||||
|
||||
progress(loaded: number, total: number): void {
|
||||
this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent)
|
||||
}
|
||||
|
||||
respond(status: number, body: unknown): void {
|
||||
this.status = status
|
||||
this.responseText = JSON.stringify(body)
|
||||
this.onload?.()
|
||||
}
|
||||
}
|
||||
|
||||
function fileItem(id: number, name: string, pending: boolean) {
|
||||
return {
|
||||
id,
|
||||
file_name: name,
|
||||
file_size: 1_200_000,
|
||||
content_type: 'image/png',
|
||||
title: '',
|
||||
description: '',
|
||||
sort_order: id,
|
||||
pending,
|
||||
created_at: '2026-10-02T10:00:00Z',
|
||||
url: `/storage/app/uploads/public/${name}`,
|
||||
thumb_url: `/storage/app/uploads/public/thumb_${name}`,
|
||||
}
|
||||
}
|
||||
|
||||
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
|
||||
[`GET ${BASE}/0/files/photos`]: { body: { data: [], meta: {} } },
|
||||
[`POST ${BASE}`]: { status: 201, body: created },
|
||||
[`GET ${BASE}/5`]: { body: created },
|
||||
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, files: File[]): Promise<void> {
|
||||
const input = wrapper.find('[data-fileupload-input]')
|
||||
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function png(name: string, size = 1024): File {
|
||||
return new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
FakeXHR.sent = []
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXHR)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('fileupload on a new record (tracer)', () => {
|
||||
it('uploads to record 0 with the form key, turns the form dirty and saves with the same key', async () => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/gadgets/create', routes())
|
||||
|
||||
// The file list of the unsaved record is read with the form's key.
|
||||
const [list] = requestsTo(calls, 'GET', `${BASE}/0/files/photos`)
|
||||
expect(list).toBeDefined()
|
||||
const key = list!.headers.get(SESSION_HEADER) ?? ''
|
||||
expect(key).toMatch(KEY_PATTERN)
|
||||
expect(new URL(list!.url).search).toBe('')
|
||||
expect(wrapper.find('[data-fileupload-dropzone]').text()).toContain('backend::lang.fileupload.default_prompt_many')
|
||||
|
||||
await choose(wrapper, [png('box.png')])
|
||||
|
||||
expect(FakeXHR.sent).toHaveLength(1)
|
||||
const upload = FakeXHR.sent[0]!
|
||||
expect(upload.method).toBe('POST')
|
||||
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-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)
|
||||
|
||||
upload.progress(50, 100)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-state="uploading"]').text()).toContain('backend::lang.fileupload.uploading')
|
||||
|
||||
upload.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
|
||||
// On a create form every file is pending, so no Unsaved chip.
|
||||
expect(wrapper.find('[data-unsaved-chip]').exists()).toBe(false)
|
||||
|
||||
// The upload alone makes the form dirty: leaving asks first.
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
const confirm = document.body.querySelector('[data-confirm-dialog]')
|
||||
expect(confirm).not.toBeNull()
|
||||
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('create')
|
||||
|
||||
await wrapper.find('#field-name').setValue('Box')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const [save] = requestsTo(calls, 'POST', BASE)
|
||||
expect(save).toBeDefined()
|
||||
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
|
||||
expect(await save!.clone().json()).toEqual({ name: 'Box' })
|
||||
|
||||
// The create follows its redirect to the saved record, with a fresh key.
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets/5')
|
||||
const [reload] = requestsTo(calls, 'GET', `${BASE}/5/files/photos`)
|
||||
expect(reload).toBeDefined()
|
||||
const next = reload!.headers.get(SESSION_HEADER) ?? ''
|
||||
expect(next).toMatch(KEY_PATTERN)
|
||||
expect(next).not.toBe(key)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-file-item]').text()).toContain('box.png')
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
await choose(wrapper, [
|
||||
new File(['x'], 'notes.txt', { type: 'text/plain' }),
|
||||
png('huge.png', 3 * (1 << 20)),
|
||||
png('a.png'),
|
||||
png('b.png'),
|
||||
png('c.png'),
|
||||
png('d.png'),
|
||||
])
|
||||
|
||||
const failed = wrapper.findAll('[data-state="failed"]')
|
||||
expect(failed).toHaveLength(2)
|
||||
expect(failed[0]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.wrong_type')
|
||||
expect(failed[1]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.too_large')
|
||||
expect(wrapper.find('[data-fileupload-too-many]').attributes('role')).toBe('alert')
|
||||
// One upload at a time: a.png is sent, b.png and c.png wait, d.png is dropped.
|
||||
expect(FakeXHR.sent).toHaveLength(1)
|
||||
expect((FakeXHR.sent[0]!.body!.get('file_data') as File).name).toBe('a.png')
|
||||
expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2)
|
||||
|
||||
FakeXHR.sent[0]!.respond(422, {
|
||||
error: { code: 'validation_failed', message: 'The given data was invalid.', details: { photos: ['The photos must be an image.'] } },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-state="failed"]')[2]!.find('[data-file-error]').text()).toContain(
|
||||
'The photos must be an image.',
|
||||
)
|
||||
expect(FakeXHR.sent).toHaveLength(2)
|
||||
expect((FakeXHR.sent[1]!.body!.get('file_data') as File).name).toBe('b.png')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user