When a public image's thumb_url or url fails to load, the fileupload field fetches its preview once through the authenticated thumb route (session key in the header) and shows it from an object URL, revoked when the item is forgotten and on unmount. The preview is reported as unavailable only when that fetch or the object-URL image fails too. Protected fields behave as before.
607 lines
26 KiB
TypeScript
607 lines
26 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, 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 Reply, 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[]): Reply {
|
|
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 { activeUploads, beginUpload } = createUploadGate()
|
|
const session: FormSession = {
|
|
key: KEY,
|
|
recordId,
|
|
routes: (name: string) => parentFileRoutes(SOURCE, recordId, name, KEY),
|
|
markDirty,
|
|
pendingChanges: ref(0),
|
|
revision: ref(0),
|
|
beginUpload,
|
|
activeUploads,
|
|
}
|
|
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, activeUploads }
|
|
}
|
|
|
|
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, 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)
|
|
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)
|
|
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 () => {
|
|
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('public preview fallback', () => {
|
|
const thumbPath = `${BASE}/7/files/photos/41/thumb`
|
|
|
|
it('previews a public image through the thumb route when its URL fails and revokes the object URL on unmount', async () => {
|
|
const create = vi.fn(() => 'blob:fallback-41')
|
|
const revoke = vi.fn()
|
|
objectUrls(create, revoke)
|
|
const { wrapper, calls } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
|
|
[`GET ${thumbPath}`]: () => ({ body: 'png-bytes' }),
|
|
},
|
|
})
|
|
expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('/storage/app/uploads/public/thumb_a.png')
|
|
expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(0)
|
|
await wrapper.find('[data-file-item] img').trigger('error')
|
|
await flushPromises()
|
|
const [thumb] = requestsTo(calls, 'GET', thumbPath)
|
|
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:fallback-41')
|
|
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(false)
|
|
expect(revoke).not.toHaveBeenCalled()
|
|
wrapper.unmount()
|
|
expect(revoke).toHaveBeenCalledWith('blob:fallback-41')
|
|
})
|
|
|
|
it('shows the preview as unavailable when the thumb route fails too', async () => {
|
|
objectUrls(vi.fn(() => 'blob:x'), vi.fn())
|
|
const { wrapper, calls } = await setup({
|
|
routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]) },
|
|
})
|
|
await wrapper.find('[data-file-item] img').trigger('error')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(1)
|
|
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-file-item] img').exists()).toBe(false)
|
|
})
|
|
|
|
it('makes no thumb request while the public URL loads', async () => {
|
|
objectUrls(vi.fn(() => 'blob:x'), vi.fn())
|
|
const { wrapper, calls } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
|
|
[`GET ${thumbPath}`]: () => ({ body: 'png-bytes' }),
|
|
},
|
|
})
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(0)
|
|
expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('/storage/app/uploads/public/thumb_a.png')
|
|
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows the preview as unavailable when the fallback image fails as well, after one thumb request', async () => {
|
|
objectUrls(vi.fn(() => 'blob:fallback-41'), vi.fn())
|
|
const { wrapper, calls } = await setup({
|
|
routes: {
|
|
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
|
|
[`GET ${thumbPath}`]: () => ({ body: 'not-an-image' }),
|
|
},
|
|
})
|
|
await wrapper.find('[data-file-item] img').trigger('error')
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:fallback-41')
|
|
await wrapper.find('[data-file-item] img').trigger('error')
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true)
|
|
expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
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' })
|
|
})
|
|
|
|
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', () => {
|
|
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')
|
|
})
|
|
})
|