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
This commit is contained in:
209
admin/tests/relation/RelationChildModal.test.ts
Normal file
209
admin/tests/relation/RelationChildModal.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// 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]])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { clone, deferredRelationSchemaFixture, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
@@ -21,12 +24,19 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
}
|
||||
}
|
||||
|
||||
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
|
||||
async function mountManager(
|
||||
overrides: Record<string, Route> = {},
|
||||
props: Record<string, unknown> = {},
|
||||
session: FormSession | null = null,
|
||||
) {
|
||||
const calls = mockApi(routes(overrides))
|
||||
const wrapper = mount(RelationManager, {
|
||||
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
|
||||
// DataTable injects the router for its row links.
|
||||
global: { plugins: [createAdminRouter(createMemoryHistory())] },
|
||||
global: {
|
||||
plugins: [createAdminRouter(createMemoryHistory())],
|
||||
provide: session ? { [FORM_SESSION as symbol]: session } : {},
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
@@ -224,3 +234,134 @@ describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
|
||||
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-14)', () => {
|
||||
const members = deferredRelationSchemaFixture.members
|
||||
const RECORDS = `${LINKED}/records`
|
||||
const PIVOT = `${LINKED}/pivot`
|
||||
const DELETE = `${LINKED}/delete`
|
||||
const first = linkedFixture.all.data[0]!
|
||||
|
||||
function withButtons(buttons: string[], extra: Record<string, unknown> = {}) {
|
||||
const schema = clone(members)
|
||||
schema.data.view.toolbarButtons = buttons
|
||||
Object.assign(schema.data, extra)
|
||||
return { body: schema }
|
||||
}
|
||||
|
||||
function session(recordId: number): FormSession & { markDirty: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
key: 'f'.repeat(43),
|
||||
recordId,
|
||||
routes: () => {
|
||||
throw new Error('no file routes in this test')
|
||||
},
|
||||
markDirty: vi.fn(),
|
||||
pendingChanges: ref(0),
|
||||
revision: ref(0),
|
||||
}
|
||||
}
|
||||
|
||||
it('opens the pivot modal on a row click when update is not declared', async () => {
|
||||
const { calls } = await mountManager({
|
||||
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
|
||||
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
|
||||
})
|
||||
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
||||
expect(requestsTo(calls, 'GET', `${PIVOT}/${first.id}`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens the view form read-only without update and without a pivot form', async () => {
|
||||
const { calls } = await mountManager({
|
||||
[`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [] }),
|
||||
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
|
||||
})
|
||||
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
|
||||
expect(modal.dataset.mode).toBe('preview')
|
||||
expect(requestsTo(calls, 'GET', `${RECORDS}/${first.id}`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers no row action without update, a pivot form or a view form', async () => {
|
||||
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [], viewForm: [] }) })
|
||||
expect(wrapper.find('[data-row-open]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('adds a trailing pivot button next to update on a belongsToMany with a pivot form', async () => {
|
||||
const { wrapper } = await mountManager({
|
||||
[`GET ${SCHEMA}`]: withButtons(['update', 'link'], { manageForm: [{ name: 'email', label: 'E-mail', type: 'text' }] }),
|
||||
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
|
||||
})
|
||||
const buttons = wrapper.findAll('[data-action="edit-pivot"]')
|
||||
expect(buttons).toHaveLength(linkedFixture.all.data.length)
|
||||
expect(buttons[0]!.attributes('aria-label')).toBe(`Edit link details: ${first.email}`)
|
||||
await buttons[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('confirms a delete with the plural count and toasts the plural result', async () => {
|
||||
const { wrapper } = await mountManager({
|
||||
[`GET ${SCHEMA}`]: withButtons(['delete']),
|
||||
[`POST ${DELETE}`]: { body: { data: { deleted: 2 }, meta: {} } },
|
||||
})
|
||||
const boxes = wrapper.findAll('tbody [role="checkbox"]')
|
||||
await boxes[0]!.trigger('click')
|
||||
await boxes[1]!.trigger('click')
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete the selected (2)? This cannot be undone.')
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 2 records', tone: 'success' })
|
||||
})
|
||||
|
||||
it('works on owner 0 with the form key on the create screen and marks the form dirty', async () => {
|
||||
const field = { ...formField('members'), deferrable: true }
|
||||
const create = session(0)
|
||||
const ZERO = `${API}/acme/demo/widgets/0/relations/members`
|
||||
const { wrapper, calls } = await mountManager(
|
||||
{
|
||||
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
|
||||
[`GET ${ZERO}`]: { body: linkedFixture.all },
|
||||
[`POST ${ZERO}/unlink`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } },
|
||||
},
|
||||
{ field, recordId: null },
|
||||
create,
|
||||
)
|
||||
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', ZERO)
|
||||
expect(list!.headers.get(SESSION_HEADER)).toBe(create.key)
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
const [unlink] = requestsTo(calls, 'POST', `${ZERO}/unlink`)
|
||||
expect(unlink!.headers.get(SESSION_HEADER)).toBe(create.key)
|
||||
expect(create.markDirty).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays hidden on the create screen when the field is not deferrable', async () => {
|
||||
const field = { ...formField('members'), deferrable: false }
|
||||
const { wrapper, calls } = await mountManager({}, { field, recordId: null }, session(0))
|
||||
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends no session key and leaves the form clean on a saved record', async () => {
|
||||
const update = session(1)
|
||||
const { wrapper, calls } = await mountManager(
|
||||
{ [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), [`POST ${UNLINK}`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } } },
|
||||
{ field: { ...formField('members'), deferrable: true } },
|
||||
update,
|
||||
)
|
||||
expect(wrapper.find('[data-relation-pending]').exists()).toBe(false)
|
||||
expect(requestsTo(calls, 'GET', LINKED)[0]!.headers.get(SESSION_HEADER)).toBeNull()
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
expect(requestsTo(calls, 'POST', UNLINK)[0]!.headers.get(SESSION_HEADER)).toBeNull()
|
||||
expect(update.markDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
119
admin/tests/relation/RelationPivotModal.test.ts
Normal file
119
admin/tests/relation/RelationPivotModal.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// RelationPivotModal (Phase 12.2, D-14; UI-SPEC section 4, pivot modal):
|
||||
// loads the link's pivot form values with the form's session key, saves
|
||||
// only the pivot form fields, toasts pivot_saved, keeps a 422 in the
|
||||
// dialog, closes and reloads on a 404, and asks before a dirty close.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import RelationPivotModal from '../../src/components/relation/RelationPivotModal.vue'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { deferredRelationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
|
||||
const PIVOT = `${API}/acme/demo/gadgets/5/relations/members/pivot/12`
|
||||
const FORM_KEY = 'p'.repeat(43)
|
||||
const schema = deferredRelationSchemaFixture.members.data
|
||||
|
||||
function dialog(): HTMLElement {
|
||||
return document.body.querySelector<HTMLElement>('[data-relation-pivot-modal]')!
|
||||
}
|
||||
|
||||
function setNote(value: string): void {
|
||||
const input = dialog().querySelector<HTMLInputElement>('#pivot-members-note')!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input'))
|
||||
}
|
||||
|
||||
function click(selector: string): void {
|
||||
;(document.body.querySelector(selector) as HTMLElement).click()
|
||||
}
|
||||
|
||||
async function open(routes: Record<string, Route>) {
|
||||
const calls = mockApi(routes)
|
||||
const wrapper = mount(RelationPivotModal, {
|
||||
props: { open: true, source, ownerId: 5, relation: 'members', schema, childId: 12, childName: 'ada@example.test', sessionKey: FORM_KEY },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('relation pivot modal', () => {
|
||||
it('loads the link details, saves the pivot fields only and toasts pivot_saved', async () => {
|
||||
const { wrapper, calls } = await open({
|
||||
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
|
||||
[`PUT ${PIVOT}`]: { body: { data: { id: 12, note: 'Owner' }, meta: {} } },
|
||||
})
|
||||
expect(dialog().textContent).toContain('Link details')
|
||||
expect(dialog().textContent).toContain('ada@example.test')
|
||||
const [get] = requestsTo(calls, 'GET', PIVOT)
|
||||
expect(get!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
|
||||
expect(dialog().querySelector<HTMLInputElement>('#pivot-members-note')!.value).toBe('Lead')
|
||||
setNote('Owner')
|
||||
await flushPromises()
|
||||
click('[data-action="save-pivot"]')
|
||||
await flushPromises()
|
||||
const [put] = requestsTo(calls, 'PUT', PIVOT)
|
||||
expect(await put!.clone().json()).toEqual({ note: 'Owner' })
|
||||
expect(put!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
expect(toasts.value.at(-1)).toMatchObject({ text: 'Link details saved', tone: 'success' })
|
||||
})
|
||||
|
||||
it('keeps a 422 in the dialog under its field', async () => {
|
||||
const { wrapper } = await open({
|
||||
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
|
||||
[`PUT ${PIVOT}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { note: ['The note field is required.'] } } } },
|
||||
})
|
||||
setNote('')
|
||||
click('[data-action="save-pivot"]')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined()
|
||||
expect(dialog().textContent).toContain('The note field is required.')
|
||||
expect(dialog().querySelector('#pivot-members-note')!.getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('closes, toasts child_gone and emits gone when the link is gone', async () => {
|
||||
const { wrapper } = await open({ [`GET ${PIVOT}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } })
|
||||
expect(wrapper.emitted('gone')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
|
||||
})
|
||||
|
||||
it('toasts the generic error and keeps the input when the save fails otherwise', async () => {
|
||||
const { wrapper } = await open({
|
||||
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
|
||||
[`PUT ${PIVOT}`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } },
|
||||
})
|
||||
setNote('Changed')
|
||||
click('[data-action="save-pivot"]')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined()
|
||||
expect(dialog().querySelector<HTMLInputElement>('#pivot-members-note')!.value).toBe('Changed')
|
||||
expect(toasts.value.at(-1)?.tone).toBe('danger')
|
||||
})
|
||||
|
||||
it('asks before closing with unsaved changes', async () => {
|
||||
const { wrapper } = await open({ [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } } })
|
||||
setNote('Changed')
|
||||
await flushPromises()
|
||||
click('[data-relation-pivot-modal] [data-action="cancel"]')
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
|
||||
click('[data-confirm-dialog] [data-action="confirm"]')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user