// RelationChildModal (Phase 12.2, D-11, D-17; UI-SPEC section 4) mounted on // its own: titles and submit labels per mode, the loading skeleton and the // load failure alert, a 422 that switches to the invalid field's tab and // focuses it, the 404 "gone" flow, delete with a confirmation, the modal's // own session key next to the form's on every call, and the confirmation // before a dirty close. Texts come from the relation schema fixture. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import RelationChildModal from '../../src/components/relation/RelationChildModal.vue' import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey' import { toasts } from '../../src/state/useToasts' import { deferredRelationSchemaFixture } from '../fixtures/typed' import { API, mockApi, requestsTo, resetState, wait, type Route } from '../helpers' const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' } const RECORDS = `${API}/acme/demo/gadgets/0/relations/parts/records` const FORM_KEY = 'f'.repeat(43) const schema = deferredRelationSchemaFixture.parts.data const record = { data: { id: 9, label: 'Bolt', notes: 'Steel' }, meta: { labels: {} } } function body(): HTMLElement { return document.body.querySelector('[data-relation-child-modal]')! } function setInput(selector: string, value: string): void { const input = body().querySelector(selector)! input.value = value input.dispatchEvent(new Event('input')) } function click(selector: string): void { ;(document.body.querySelector(selector) as HTMLElement).click() } async function open(mode: 'create' | 'update' | 'preview', routes: Record = {}, extra: Record = {}) { const calls = mockApi(routes) const wrapper = mount(RelationChildModal, { props: { open: true, source, ownerId: 0, relation: 'parts', schema, mode, childId: mode === 'create' ? null : 9, sessionKey: FORM_KEY, canDelete: true, ...extra, }, attachTo: document.body, }) await flushPromises() return { wrapper, calls } } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('titles and modes', () => { it('names each mode with its title and submit label', async () => { const create = await open('create') expect(body().dataset.mode).toBe('create') expect(body().querySelector('h2')!.textContent).toBe('New record') expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Create record') expect(body().querySelector('[data-action="delete-child"]')).toBeNull() create.wrapper.unmount() document.body.innerHTML = '' const update = await open('update', { [`GET ${RECORDS}/9`]: { body: record } }) expect(body().querySelector('h2')!.textContent).toBe('Edit record') expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Save record') expect(body().querySelector('[data-action="delete-child"]')).not.toBeNull() expect(body().querySelector('#child-parts-label')!.value).toBe('Bolt') update.wrapper.unmount() document.body.innerHTML = '' await open('preview', { [`GET ${RECORDS}/9`]: { body: record } }) expect(body().querySelector('h2')!.textContent).toBe('Record preview') expect(body().querySelector('[data-action="save-child"]')).toBeNull() const input = body().querySelector('#child-parts-label')! expect(input.readOnly || input.disabled || input.closest('fieldset')?.disabled).toBeTruthy() }) it('shows a skeleton while the child loads and an alert when it cannot be read', async () => { let release: (() => void) | null = null const slow: Route = () => new Promise((resolve) => { release = () => resolve({ body: record }) }) const { wrapper } = await open('update', { [`GET ${RECORDS}/9`]: slow }) expect(body().querySelector('[data-child-loading]')).not.toBeNull() release!() await flushPromises() expect(body().querySelector('[data-child-loading]')).toBeNull() wrapper.unmount() document.body.innerHTML = '' await open('update', { [`GET ${RECORDS}/9`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } } }) const alert = body().querySelector('[data-child-load-failed]')! expect(alert).not.toBeNull() expect(body().querySelector('[data-action="save-child"]')?.hasAttribute('disabled') ?? true).toBe(true) }) }) describe('saving', () => { it('sends both keys, the modal key new on every open, and closes with a toast', async () => { const { wrapper, calls } = await open('create', { [`POST ${RECORDS}`]: { status: 201, body: record } }) setInput('#child-parts-label', 'Bolt') await flushPromises() click('[data-action="save-child"]') await flushPromises() const [post] = requestsTo(calls, 'POST', RECORDS) expect(post!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? '' expect(childKey).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(childKey).not.toBe(FORM_KEY) expect(await post!.clone().json()).toEqual({ label: 'Bolt' }) expect(wrapper.emitted('update:open')).toEqual([[false]]) expect(wrapper.emitted('changed')).toEqual([['created']]) expect(toasts.value.at(-1)).toMatchObject({ text: 'Record created', tone: 'success' }) await wrapper.setProps({ open: false }) await wrapper.setProps({ open: true }) await flushPromises() setInput('#child-parts-label', 'Nut') click('[data-action="save-child"]') await flushPromises() const second = requestsTo(calls, 'POST', RECORDS)[1]! expect(second.headers.get(CHILD_SESSION_HEADER)).not.toBe(childKey) }) it('switches to the tab of the first invalid field and focuses it on a 422', async () => { await open('create', { [`POST ${RECORDS}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { notes: ['The notes field is required.'] } } } }, }) setInput('#child-parts-label', 'Bolt') click('[data-action="save-child"]') await flushPromises() await wait(0) const notes = body().querySelector('#child-parts-notes') expect(notes).not.toBeNull() expect(document.activeElement).toBe(notes) expect(body().textContent).toContain('The notes field is required.') expect(body().querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain('Details') }) it('closes, toasts child_gone and emits gone on a 404', async () => { const { wrapper } = await open('update', { [`GET ${RECORDS}/9`]: { body: record }, [`PUT ${RECORDS}/9`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } }, }) setInput('#child-parts-label', 'Gone') click('[data-action="save-child"]') await flushPromises() expect(wrapper.emitted('update:open')).toEqual([[false]]) expect(wrapper.emitted('gone')).toHaveLength(1) expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' }) }) it('deletes the child after a confirmation', async () => { const { wrapper, calls } = await open('update', { [`GET ${RECORDS}/9`]: { body: record }, [`POST ${API}/acme/demo/gadgets/0/relations/parts/delete`]: { body: { data: { deleted: 1 }, meta: {} } }, }) click('[data-action="delete-child"]') await flushPromises() const confirm = document.body.querySelector('[data-confirm-dialog]')! expect(confirm.textContent).toContain('Delete this record? This cannot be undone.') click('[data-confirm-dialog] [data-action="confirm"]') await flushPromises() const [del] = requestsTo(calls, 'POST', `${API}/acme/demo/gadgets/0/relations/parts/delete`) expect(await del!.clone().json()).toEqual({ ids: [9] }) expect(del!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) expect(wrapper.emitted('changed')).toEqual([['deleted']]) expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 1 record' }) }) it('asks before closing with unsaved changes', async () => { const { wrapper } = await open('create') click('[data-action="close"]') await flushPromises() expect(document.body.querySelector('[data-confirm-dialog]')).toBeNull() expect(wrapper.emitted('update:open')).toEqual([[false]]) await wrapper.setProps({ open: false }) await wrapper.setProps({ open: true }) await flushPromises() setInput('#child-parts-label', 'Draft') await flushPromises() click('[data-relation-child-modal] [data-action="close"]') await flushPromises() expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull() click('[data-confirm-dialog] [data-action="cancel"]') await flushPromises() expect(wrapper.emitted('update:open')).toHaveLength(1) click('[data-relation-child-modal] [data-action="close"]') await flushPromises() click('[data-confirm-dialog] [data-action="confirm"]') await flushPromises() expect(wrapper.emitted('update:open')).toEqual([[false], [false]]) }) })