Files
summercms/admin/tests/relation/RelationChildModal.test.ts
Jakub Zych d5494faa81 test(12.2-05): SPA unit tests for the phase and the check-phase12.2 gate
- 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
2026-10-02 20:51:36 +02:00

210 lines
9.2 KiB
TypeScript

// 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<HTMLElement>('[data-relation-child-modal]')!
}
function setInput(selector: string, value: string): void {
const input = body().querySelector<HTMLInputElement | HTMLTextAreaElement>(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<string, Route> = {}, extra: Record<string, unknown> = {}) {
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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLTextAreaElement>('#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]])
})
})