// 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('[data-relation-pivot-modal]')! } function setNote(value: string): void { const input = dialog().querySelector('#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) { 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('#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('#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]]) }) })