// 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. The datepicker part runs in a // fixed non-UTC zone: a stored UTC instant shows as local time and goes back // unchanged, a date goes out as YYYY-MM-DD. A deferrable hasMany manager // on the create screen works on owner id 0 with the same key, and its child // modal adds its own key for the child's save. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils' import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date' import { setBundle } from '../../src/app/i18n' import type { RelationSchema } from '../../src/api/types' import { CHILD_SESSION_HEADER, 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, relationSchemaFixture } 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 = {} 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: {} } } // A hasMany of parts: create and delete in the toolbar, a one-field manage form. const partsSchema: RelationSchema = { ...relationSchemaFixture.data, name: 'parts', label: 'Parts', kind: 'hasMany', deferrable: true, view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'delete'], showSearch: false }, manageForm: [{ name: 'title', label: 'Title', type: 'text', required: true }], } const noParts = { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } function routes(overrides: Record = {}): Record { 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: {} } }, [`GET ${BASE}/schema/relation/parts`]: { body: { data: partsSchema, meta: {} } }, [`GET ${BASE}/0/relations/parts`]: { body: noParts }, [`GET ${BASE}/5/relations/parts`]: { body: noParts }, [`GET ${BASE}/7/relations/parts`]: { body: noParts }, ...overrides, } } async function choose(wrapper: VueWrapper, files: File[]): Promise { 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-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) 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('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()) 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') }) }) const saved = { data: { id: 7, name: 'Box', starts_at: '2026-10-02T10:30:00Z', released_on: null }, meta: { labels: {} }, } function editRoutes(): Record { return routes({ [`GET ${BASE}/7`]: { body: saved }, [`GET ${BASE}/7/files/photos`]: { body: { data: [], meta: {} } }, [`PUT ${BASE}/7`]: { body: saved }, }) } function segment(field: DOMWrapper, part: string): DOMWrapper { return field.find(`[data-reka-date-field-segment="${part}"]`) } async function typeInto(field: DOMWrapper, part: string, digits: string): Promise { const target = segment(field, part) for (const key of digits) { await target.trigger('keydown', { key }) } } describe('datepicker values in a fixed time zone', () => { beforeEach(() => setLocalTimeZone('Europe/Warsaw')) afterEach(() => resetLocalTimeZone()) it('shows a stored datetime in local time, saves it unchanged and sends a typed date as YYYY-MM-DD', async () => { const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes()) const starts = wrapper.find('[data-field="starts_at"]') expect(segment(starts, 'hour').text()).toBe('12') expect(segment(starts, 'minute').text()).toBe('30') expect(segment(starts, 'timeZoneName').exists()).toBe(true) // Not required and set: the clear button is offered next to the trigger. expect(starts.find('[data-action="clear"]').attributes('aria-label')).toBe('backend::lang.datepicker.clear') expect(starts.find('[data-action="open-calendar"]').attributes('aria-label')).toBe( 'backend::lang.datepicker.open_calendar', ) // The label points at the field's id; no native date input is rendered by the field itself. expect(wrapper.find('label[for="field-starts_at"]').exists()).toBe(true) const released = wrapper.find('[data-field="released_on"]') expect(segment(released, 'day').attributes('data-placeholder')).toBeDefined() // Polish segment order: day, month, year. const parts = released .findAll('[data-reka-date-field-segment]') .map((el) => el.attributes('data-reka-date-field-segment')) .filter((part) => part !== 'literal' && part !== 'trigger') expect(parts).toEqual(['day', 'month', 'year']) await typeInto(released, 'day', '02') await typeInto(released, 'month', '10') await typeInto(released, 'year', '2026') await flushPromises() await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const [put] = requestsTo(calls, 'PUT', `${BASE}/7`) expect(put).toBeDefined() const body = await put!.clone().json() expect(body.starts_at).toBe('2026-10-02T10:30:00Z') expect(body.released_on).toBe('2026-10-02') }) it('converts an edited local time back to UTC', async () => { const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes()) const starts = wrapper.find('[data-field="starts_at"]') await segment(starts, 'minute').trigger('keydown', { key: 'ArrowUp' }) await flushPromises() expect(segment(starts, 'minute').text()).toBe('31') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const [put] = requestsTo(calls, 'PUT', `${BASE}/7`) expect((await put!.clone().json()).starts_at).toBe('2026-10-02T10:31:00Z') }) it('clears an optional value to null', async () => { const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes()) await wrapper.find('[data-field="starts_at"] [data-action="clear"]').trigger('click') await flushPromises() expect(wrapper.find('[data-field="starts_at"] [data-action="clear"]').exists()).toBe(false) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const [put] = requestsTo(calls, 'PUT', `${BASE}/7`) expect((await put!.clone().json()).starts_at).toBeNull() }) }) describe('relation manager on a new record', () => { it('creates a child on owner 0 with the form key and its own key, turns the form dirty and saves with the key', async () => { const { wrapper, calls } = await mountApp( '/acme/demo/gadgets/create', routes({ [`POST ${BASE}/0/relations/parts/records`]: { status: 201, body: { data: { id: 90, title: 'Bolt' }, meta: { labels: {} } }, }, }), { attach: true }, ) const manager = wrapper.find('[data-relation-manager]') expect(manager.attributes('data-deferred')).toBe('true') expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note') const [list] = requestsTo(calls, 'GET', `${BASE}/0/relations/parts`) const key = list!.headers.get(SESSION_HEADER) ?? '' expect(key).toMatch(KEY_PATTERN) expect(new URL(list!.url).search).not.toContain(key) // create is the toolbar's only primary; delete waits for a selection. expect(manager.find('[data-action="delete"]').attributes('disabled')).toBeDefined() await manager.find('[data-action="create"]').trigger('click') await flushPromises() const modal = document.body.querySelector('[data-relation-child-modal]') expect(modal).not.toBeNull() expect(modal!.dataset.mode).toBe('create') const title = modal!.querySelector('#child-parts-title')! title.value = 'Bolt' title.dispatchEvent(new Event('input')) await flushPromises() ;(modal!.querySelector('[data-action="save-child"]') as HTMLButtonElement).click() await flushPromises() const [post] = requestsTo(calls, 'POST', `${BASE}/0/relations/parts/records`) expect(post).toBeDefined() expect(post!.headers.get(SESSION_HEADER)).toBe(key) const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? '' expect(childKey).toMatch(KEY_PATTERN) expect(childKey).not.toBe(key) expect(await post!.clone().json()).toEqual({ title: 'Bolt' }) expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull() // The list reloads with the same key. expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(2) // The deferred child alone makes the form dirty. 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() 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!.headers.get(SESSION_HEADER)).toBe(key) }) it('hides a non-deferrable manager on the create screen', async () => { const schema = structuredClone(deferredFormSchemaFixture) const parts = schema.data.fields.find((field) => field.name === 'parts')! parts.deferrable = false const { wrapper, calls } = await mountApp( '/acme/demo/gadgets/create', routes({ [`GET ${BASE}/schema/form`]: { body: schema } }), ) expect(wrapper.find('[data-relation-manager]').exists()).toBe(false) expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(0) }) })