- sessionKey, dateFormat, FileuploadField (protected thumbnails and keyboard reorder backstops), RelationChildModal, RelationPivotModal, RelationManager row actions and create-screen deferral, date and time list cells; typed deferred relation-schema and file-list fixtures - scripts/check-phase12.2.sh: go, security (named tests, refuses missing or skipped), spa, openapi, dist, docs, hygiene and app stages, a detector self-test, one PASS or FAIL line per stage under --all
438 lines
19 KiB
TypeScript
438 lines
19 KiB
TypeScript
// FileuploadField (Phase 12.2, D-03, D-08 to D-10; UI-SPEC section 3)
|
|
// mounted on its own with the form session FormView would provide. HTTP
|
|
// goes through the fetch mock and real file route adapters, uploads
|
|
// through a hand-answered fake XMLHttpRequest. Covers UI-SPEC backstops 3
|
|
// (protected thumbnails: session header, object URL, revoked on unmount)
|
|
// and 4 (keyboard reorder: focus kept, announced, one debounced request).
|
|
// The language bundle has no fileupload strings, so texts are their keys.
|
|
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 { 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'
|
|
|
|
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
|
|
const KEY = 'k'.repeat(43)
|
|
const BASE = `${API}/acme/demo/gadgets`
|
|
|
|
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 photos(extra: Partial<FormField> = {}): FormField {
|
|
return {
|
|
name: 'photos',
|
|
label: 'Photos',
|
|
type: 'fileupload',
|
|
mode: 'image',
|
|
multiple: true,
|
|
maxFiles: 3,
|
|
fileTypes: ['jpg', 'png'],
|
|
maxFilesize: 2,
|
|
useCaption: true,
|
|
...extra,
|
|
} as FormField
|
|
}
|
|
|
|
function item(id: number, name: string, extra: Partial<FileItem> = {}): FileItem {
|
|
return {
|
|
id,
|
|
file_name: name,
|
|
file_size: 1200,
|
|
content_type: 'image/png',
|
|
title: '',
|
|
description: '',
|
|
sort_order: id,
|
|
pending: false,
|
|
created_at: '2026-10-02T10:00:00Z',
|
|
url: `/storage/app/uploads/public/${name}`,
|
|
thumb_url: `/storage/app/uploads/public/thumb_${name}`,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
function listOf(files: FileItem[]): Route {
|
|
return { body: { data: files, meta: {} } }
|
|
}
|
|
|
|
interface Setup {
|
|
field?: FormField
|
|
recordId?: number
|
|
routes?: Record<string, Route>
|
|
}
|
|
|
|
async function setup(options: Setup = {}) {
|
|
const recordId = options.recordId ?? 7
|
|
const field = options.field ?? photos()
|
|
const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) })
|
|
const markDirty = vi.fn()
|
|
const session: FormSession = {
|
|
key: KEY,
|
|
recordId,
|
|
routes: (name: string) => parentFileRoutes(SOURCE, recordId, name, KEY),
|
|
markDirty,
|
|
pendingChanges: ref(0),
|
|
revision: ref(0),
|
|
}
|
|
const wrapper = mount(FileuploadField, {
|
|
props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' },
|
|
global: { provide: { [FORM_SESSION as symbol]: session } },
|
|
attachTo: document.body,
|
|
})
|
|
await flushPromises()
|
|
return { wrapper, calls, markDirty }
|
|
}
|
|
|
|
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' })
|
|
}
|
|
|
|
function names(wrapper: VueWrapper): string[] {
|
|
return wrapper.findAll('[data-file-item]').map((li) => li.find('img').attributes('alt') ?? li.text())
|
|
}
|
|
|
|
// URL.createObjectURL and revokeObjectURL replaced for one test; the
|
|
// originals come back in afterEach.
|
|
const originalUrls = { create: URL.createObjectURL, revoke: URL.revokeObjectURL }
|
|
function objectUrls(create: (blob: Blob) => string, revoke: (url: string) => void): void {
|
|
URL.createObjectURL = create as typeof URL.createObjectURL
|
|
URL.revokeObjectURL = revoke
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
FakeXHR.sent = []
|
|
vi.stubGlobal('XMLHttpRequest', FakeXHR)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
URL.createObjectURL = originalUrls.create
|
|
URL.revokeObjectURL = originalUrls.revoke
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('dropzone and client checks', () => {
|
|
it('shows the prompt and the limits line, and accepts the allowed types', async () => {
|
|
const { wrapper, calls } = await setup()
|
|
const [list] = requestsTo(calls, 'GET', `${BASE}/7/files/photos`)
|
|
expect(list!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
const zone = wrapper.find('[data-fileupload-dropzone]')
|
|
expect(zone.text()).toContain('backend::lang.fileupload.default_prompt_many')
|
|
expect(wrapper.find('[data-fileupload-limits]').text()).toBe(
|
|
'backend::lang.fileupload.limits_types · backend::lang.fileupload.limits_size · backend::lang.fileupload.limits_count',
|
|
)
|
|
expect(wrapper.find('[data-fileupload-input]').attributes('accept')).toBe('.jpg,.png')
|
|
expect(wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeDefined()
|
|
const single = await setup({ field: photos({ name: 'cover', multiple: false, prompt: 'Drop a cover' }), routes: { [`GET ${BASE}/7/files/cover`]: listOf([]) } })
|
|
expect(single.wrapper.find('[data-fileupload-dropzone]').text()).toContain('Drop a cover')
|
|
expect(single.wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeUndefined()
|
|
})
|
|
|
|
it('refuses a wrong type and an oversized file and reports files past maxFiles', async () => {
|
|
const { wrapper } = await setup()
|
|
await choose(wrapper, [new File(['x'], 'a.gif', { type: 'image/gif' }), png('big.png', 3 << 20), png('a.png'), png('b.png'), png('c.png'), png('d.png'), png('e.png')])
|
|
const failed = wrapper.findAll('[data-state="failed"]')
|
|
expect(failed.map((f) => f.find('[data-file-error]').text())).toEqual([
|
|
'backend::lang.fileupload.upload_error: backend::lang.fileupload.wrong_type',
|
|
'backend::lang.fileupload.upload_error: backend::lang.fileupload.too_large',
|
|
])
|
|
const alert = wrapper.find('[data-fileupload-too-many]')
|
|
expect(alert.attributes('role')).toBe('alert')
|
|
expect(alert.text()).toContain('backend::lang.fileupload.too_many')
|
|
expect(FakeXHR.sent).toHaveLength(1)
|
|
expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('upload states', () => {
|
|
it('goes queued, uploading with progress, done; a server failure can be retried', async () => {
|
|
const { wrapper, markDirty } = await setup()
|
|
await choose(wrapper, [png('a.png'), png('b.png')])
|
|
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued')
|
|
const first = FakeXHR.sent[0]!
|
|
expect(first.headers[SESSION_HEADER]).toBe(KEY)
|
|
first.progress(30, 60)
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true)
|
|
first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } })
|
|
await flushPromises()
|
|
expect(markDirty).not.toHaveBeenCalled()
|
|
const failed = wrapper.find('[data-state="failed"]')
|
|
expect(failed.find('[data-file-error]').text()).toContain('Server error')
|
|
// The next file is sent as soon as the first one failed.
|
|
expect(FakeXHR.sent).toHaveLength(2)
|
|
FakeXHR.sent[1]!.respond(201, { data: item(51, 'b.png', { pending: true }), meta: {} })
|
|
await flushPromises()
|
|
expect(markDirty).toHaveBeenCalledTimes(1)
|
|
await failed.find('[data-action="retry"]').trigger('click')
|
|
await flushPromises()
|
|
expect(FakeXHR.sent).toHaveLength(3)
|
|
expect((FakeXHR.sent[2]!.body!.get('file_data') as File).name).toBe('a.png')
|
|
FakeXHR.sent[2]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} })
|
|
await flushPromises()
|
|
expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2)
|
|
expect(markDirty).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('shows the Unsaved chip on pending files of a saved record only', async () => {
|
|
const pending = [item(41, 'a.png', { pending: true }), item(42, 'b.png')]
|
|
const update = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: listOf(pending) } })
|
|
expect(update.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(1)
|
|
const create = await setup({ recordId: 0, routes: { [`GET ${BASE}/0/files/photos`]: listOf(pending) } })
|
|
expect(create.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(0)
|
|
})
|
|
|
|
it('removes a saved file through the session and marks the form dirty', async () => {
|
|
const { wrapper, calls, markDirty } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png'), item(42, 'b.png')]),
|
|
[`DELETE ${BASE}/7/files/photos/41`]: { body: { data: { removed: true, pending: true }, meta: {} } },
|
|
},
|
|
})
|
|
await wrapper.findAll('[data-action="remove-file"]')[0]!.trigger('click')
|
|
await flushPromises()
|
|
const [remove] = requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/41`)
|
|
expect(remove!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
expect(markDirty).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.findAll('[data-file-item]')).toHaveLength(1)
|
|
})
|
|
|
|
it('puts a file back and toasts when the removal fails', async () => {
|
|
const { wrapper } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
|
|
[`DELETE ${BASE}/7/files/photos/41`]: { status: 404, body: { error: { code: 'not_found', message: 'Gone', details: {} } } },
|
|
},
|
|
})
|
|
await wrapper.find('[data-action="remove-file"]').trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.findAll('[data-file-item]')).toHaveLength(1)
|
|
expect(toasts.value.at(-1)).toMatchObject({ text: 'Gone', tone: 'danger' })
|
|
})
|
|
})
|
|
|
|
describe('protected thumbnails (backstop 3)', () => {
|
|
it('fetches the thumb with the session header, shows it from an object URL and revokes it on unmount', async () => {
|
|
const create = vi.fn(() => 'blob:thumb-41')
|
|
const revoke = vi.fn()
|
|
objectUrls(create, revoke)
|
|
const { wrapper, calls } = await setup({
|
|
field: photos({ protected: true }),
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]),
|
|
[`GET ${BASE}/7/files/photos/41/thumb`]: () => ({ body: 'png-bytes' }),
|
|
},
|
|
})
|
|
await flushPromises()
|
|
const [thumb] = requestsTo(calls, 'GET', `${BASE}/7/files/photos/41/thumb`)
|
|
expect(thumb).toBeDefined()
|
|
expect(thumb!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
expect(new URL(thumb!.url).search).toBe('')
|
|
expect(create).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:thumb-41')
|
|
expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false)
|
|
expect(revoke).not.toHaveBeenCalled()
|
|
wrapper.unmount()
|
|
expect(revoke).toHaveBeenCalledWith('blob:thumb-41')
|
|
})
|
|
|
|
it('shows no preview when the thumb cannot be fetched', async () => {
|
|
objectUrls(vi.fn(() => 'blob:x'), vi.fn())
|
|
const { wrapper } = await setup({
|
|
field: photos({ protected: true }),
|
|
routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]) },
|
|
})
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('keyboard reorder (backstop 4)', () => {
|
|
const three = [item(41, 'a.png'), item(42, 'b.png'), item(43, 'c.png')]
|
|
|
|
it('moves the item, keeps focus on its handle, announces the move and sends one request after 400ms', async () => {
|
|
const { wrapper, calls } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf(three),
|
|
[`POST ${BASE}/7/files/photos/reorder`]: { body: { data: [three[1], three[2], three[0]], meta: {} } },
|
|
},
|
|
})
|
|
const handle = wrapper.findAll('[data-handle]')[0]!
|
|
const uid = handle.attributes('data-handle')
|
|
;(handle.element as HTMLElement).focus()
|
|
keydown(handle.element, 'ArrowDown')
|
|
await flushPromises()
|
|
let focused = document.activeElement as HTMLElement
|
|
expect(focused.dataset.handle).toBe(uid)
|
|
keydown(focused, 'ArrowDown')
|
|
await flushPromises()
|
|
focused = document.activeElement as HTMLElement
|
|
expect(focused.dataset.handle).toBe(uid)
|
|
expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png'])
|
|
expect(wrapper.find('[data-fileupload-live]').text()).toBe('backend::lang.fileupload.moved')
|
|
// Up at the top and down at the bottom do nothing.
|
|
keydown(focused, 'ArrowDown')
|
|
await flushPromises()
|
|
expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png'])
|
|
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(0)
|
|
await wait(450)
|
|
await flushPromises()
|
|
const sent = requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
expect(await sent[0]!.clone().json()).toEqual({ ids: [42, 43, 41] })
|
|
})
|
|
|
|
it('restores the order and toasts when the reorder fails', async () => {
|
|
const { wrapper } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf(three),
|
|
[`POST ${BASE}/7/files/photos/reorder`]: { status: 422, body: { error: { code: 'validation_failed', message: 'x', details: {} } } },
|
|
},
|
|
})
|
|
keydown(wrapper.findAll('[data-handle]')[2]!.element, 'ArrowUp')
|
|
await flushPromises()
|
|
expect(names(wrapper)).toEqual(['a.png', 'c.png', 'b.png'])
|
|
await wait(450)
|
|
await flushPromises()
|
|
expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png'])
|
|
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' })
|
|
})
|
|
})
|
|
|
|
describe('caption modal', () => {
|
|
it('saves the title and description at once and shows a 422 under its input', async () => {
|
|
let reply: Route = { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { title: ['The title may not be greater than 255 characters.'] } } } }
|
|
const { wrapper, calls, markDirty } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
|
|
[`PUT ${BASE}/7/files/photos/41`]: (request) => (typeof reply === 'function' ? reply(request) : reply),
|
|
},
|
|
})
|
|
await wrapper.find('[data-caption]').trigger('click')
|
|
await flushPromises()
|
|
const modal = document.body.querySelector<HTMLElement>('[data-caption-modal]')!
|
|
expect(modal).not.toBeNull()
|
|
const title = modal.querySelector<HTMLInputElement>('[data-caption-title]')!
|
|
title.value = 'Front'
|
|
title.dispatchEvent(new Event('input'))
|
|
;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
expect(title.getAttribute('aria-invalid')).toBe('true')
|
|
expect(modal.textContent).toContain('The title may not be greater than 255 characters.')
|
|
|
|
reply = { body: { data: item(41, 'a.png', { title: 'Front' }), meta: {} } }
|
|
;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
const puts = requestsTo(calls, 'PUT', `${BASE}/7/files/photos/41`)
|
|
expect(puts).toHaveLength(2)
|
|
expect(puts[1]!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
expect(await puts[1]!.clone().json()).toEqual({ title: 'Front', description: '' })
|
|
expect(document.body.querySelector('[data-caption-modal]')).toBeNull()
|
|
expect(markDirty).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('read-only and load failure', () => {
|
|
it('renders files without controls when read-only, and an empty line without files', async () => {
|
|
const shown = await setup({ field: photos({ readOnly: true }), routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]) } })
|
|
expect(shown.wrapper.find('[data-fileupload-dropzone]').exists()).toBe(false)
|
|
expect(shown.wrapper.find('[data-action="remove-file"]').exists()).toBe(false)
|
|
expect(shown.wrapper.find('[data-handle]').exists()).toBe(false)
|
|
expect(shown.wrapper.find('[data-caption]').exists()).toBe(false)
|
|
const empty = await setup({ field: photos({ readOnly: true }) })
|
|
expect(empty.wrapper.find('[data-fileupload-empty]').text()).toBe('backend::lang.fileupload.empty')
|
|
})
|
|
|
|
it('shows the load_failed alert when the list cannot be read', async () => {
|
|
const { wrapper } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } } })
|
|
const alert = wrapper.find('[data-fileupload-failed]')
|
|
expect(alert.attributes('role')).toBe('alert')
|
|
expect(alert.text()).toBe('backend::lang.fileupload.load_failed')
|
|
})
|
|
})
|
|
|
|
describe('file rows of a protected field', () => {
|
|
it('lists captions and pending files, and downloads through an object URL revoked on unmount', async () => {
|
|
const create = vi.fn(() => 'blob:download-43')
|
|
const revoke = vi.fn()
|
|
objectUrls(create, revoke)
|
|
const clicked: string[] = []
|
|
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
|
clicked.push(`${this.href}|${this.download}`)
|
|
})
|
|
const field = photos({ name: 'docs', mode: 'file', protected: true, fileTypes: ['pdf', 'png'], maxFiles: 5 })
|
|
const { wrapper, calls } = await setup({
|
|
field,
|
|
routes: {
|
|
[`GET ${BASE}/7/files/docs`]: { body: deferredFilesFixture },
|
|
[`GET ${BASE}/7/files/docs/41/thumb`]: { body: 'png' },
|
|
[`GET ${BASE}/7/files/docs/42/thumb`]: { body: 'png' },
|
|
[`GET ${BASE}/7/files/docs/43/download`]: { body: 'pdf' },
|
|
},
|
|
})
|
|
expect(wrapper.find('[data-fileupload-rows]').exists()).toBe(true)
|
|
const rows = wrapper.findAll('[data-file-item]')
|
|
expect(rows).toHaveLength(3)
|
|
expect(rows[0]!.text()).toContain('Front')
|
|
expect(rows[1]!.find('[data-unsaved-chip]').exists()).toBe(true)
|
|
expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false)
|
|
await rows[2]!.find('[data-action="download"]').trigger('click')
|
|
await flushPromises()
|
|
const [download] = requestsTo(calls, 'GET', `${BASE}/7/files/docs/43/download`)
|
|
expect(download!.headers.get(SESSION_HEADER)).toBe(KEY)
|
|
expect(clicked).toEqual(['blob:download-43|manual.pdf'])
|
|
click.mockRestore()
|
|
wrapper.unmount()
|
|
expect(revoke).toHaveBeenCalledWith('blob:download-43')
|
|
})
|
|
})
|