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, 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' } const SCHEMA = `${API}/acme/demo/widgets/schema/relation/members` const LINKED = `${API}/acme/demo/widgets/1/relations/members` const UNLINK = `${LINKED}/unlink` const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } }) function routes(overrides: Record = {}): Record { return { [`GET ${SCHEMA}`]: { body: relationSchemaFixture }, [`GET ${LINKED}`]: { body: linkedFixture.all }, ...overrides, } } async function mountManager( overrides: Record = {}, props: Record = {}, 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())], provide: session ? { [FORM_SESSION as symbol]: session } : {}, }, attachTo: document.body, }) await flushPromises() return { wrapper, calls } } async function selectFirstAndUnlink(wrapper: VueWrapper) { await wrapper.find('tbody [role="checkbox"]').trigger('click') await wrapper.find('[data-action="unlink"]').trigger('click') await flushPromises() ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click() await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('relation manager (D-05, design screen 5)', () => { it('renders nothing and requests nothing without a saved record or a source', async () => { for (const props of [{ recordId: null }, { source: null }]) { const { wrapper, calls } = await mountManager({}, props) expect(wrapper.find('[data-relation-manager]').exists()).toBe(false) expect(calls).toHaveLength(0) wrapper.unmount() } }) it('is a region labelled by its heading and described by its comment', async () => { const { wrapper } = await mountManager() const region = wrapper.find('[data-relation-manager]') expect(region.element.tagName).toBe('SECTION') expect(region.attributes('id')).toBe('field-members') expect(region.attributes('tabindex')).toBe('-1') expect(region.attributes('aria-labelledby')).toBe('field-members-heading') expect(wrapper.find('#field-members-heading').text()).toBe('Members') expect(region.attributes('aria-describedby')).toBe('field-members-comment') expect(wrapper.find('#field-members-comment').text()).toBe('People who may edit this widget') expect(wrapper.find('[data-action="link"]').attributes('aria-haspopup')).toBe('dialog') }) it('uses the relation key of the field, not its name, for the endpoints', async () => { const field = { ...formField('members'), name: 'team', relation: 'members' } const { wrapper, calls } = await mountManager({}, { field }) expect(wrapper.find('[data-relation-manager]').attributes('data-relation-name')).toBe('members') expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1) }) it('announces a failed schema or list load as an alert', async () => { const schemaDown = await mountManager({ [`GET ${SCHEMA}`]: failure(500, 'Boom') }) expect(schemaDown.wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać powiązanych rekordów.') expect(requestsTo(schemaDown.calls, 'GET', LINKED)).toHaveLength(0) schemaDown.wrapper.unmount() const listDown = await mountManager({ [`GET ${LINKED}`]: failure(403, 'Forbidden') }) expect(listDown.wrapper.find('[role="alert"]').exists()).toBe(true) expect(listDown.wrapper.find('table').exists()).toBe(false) }) it('cycles sorting asc, desc, none through the linked query and resets the page', async () => { const { wrapper, calls } = await mountManager() const header = () => wrapper.find('[data-sort="email"]') await header().trigger('click') await flushPromises() await header().trigger('click') await flushPromises() await header().trigger('click') await flushPromises() const queries = requestsTo(calls, 'GET', LINKED).map((request) => queryOf(request)) expect(queries.map((query) => [query.get('sort'), query.get('dir'), query.get('page')])).toEqual([ [null, null, '1'], ['email', 'asc', '1'], ['email', 'desc', '1'], [null, null, '1'], ]) expect(wrapper.find('th[aria-sort]').attributes('aria-sort')).toBe('none') }) it('pages the linked rows only when there is more than one page', async () => { expect((await mountManager()).wrapper.find('[data-page="next"]').exists()).toBe(false) const paged = clone(linkedFixture.all) paged.meta = { page: 1, per_page: 3, total: 5, last_page: 2 } const { wrapper, calls } = await mountManager({ [`GET ${LINKED}`]: { body: paged } }) await wrapper.find('[data-page="next"]').trigger('click') await flushPromises() expect(queryOf(requestsTo(calls, 'GET', LINKED).at(-1)!).get('page')).toBe('2') }) it('shows no unlink column or button when the view does not declare unlink', async () => { const schema = clone(relationSchemaFixture) schema.data.view.toolbarButtons = ['link'] schema.data.view.showSearch = false const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: schema } }) expect(wrapper.find('[data-action="unlink"]').exists()).toBe(false) expect(wrapper.find('[role="checkbox"]').exists()).toBe(false) expect(wrapper.find('[data-relation-search]').exists()).toBe(false) }) it('toasts the server message when an unlink is refused and keeps the selection', async () => { const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: failure(409, 'Owner cannot be removed') }) await selectFirstAndUnlink(wrapper) expect(toasts.value[0]).toMatchObject({ text: 'Owner cannot be removed', tone: 'danger' }) expect(wrapper.find('tbody tr').attributes('aria-selected')).toBe('true') }) it('toasts the generic error when the unlink cannot reach the server', async () => { const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: () => { throw new TypeError('offline') }, }) await selectFirstAndUnlink(wrapper) expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' }) expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined() }) }) describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => { const editable = { ...relationSchemaFixture, data: { ...relationSchemaFixture.data, view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'update', 'delete', 'link'] }, manageForm: [{ name: 'name', label: 'Name', type: 'text' }], }, } const RECORDS = `${LINKED}/records` const DELETE = `${LINKED}/delete` it('renders the toolbar in order with create as the only primary button', async () => { const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable } }) const actions = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action')) expect(actions).toEqual(['create', 'delete', 'link']) expect(wrapper.find('[data-action="link"]').classes()).not.toContain('bg-primary') expect(wrapper.find('[data-action="create"]').classes()).toContain('bg-primary') }) it('opens the child modal in update mode from the row button and saves the child', async () => { const first = linkedFixture.all.data[0]! const { calls } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable }, [`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } }, [`PUT ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } }, }) ;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click() await flushPromises() const modal = document.body.querySelector('[data-relation-child-modal]')! expect(modal.dataset.mode).toBe('update') const input = modal.querySelector('#child-members-name')! expect(input.value).toBe(String(first.name)) input.value = 'Renamed' input.dispatchEvent(new Event('input')) await flushPromises() ;(modal.querySelector('[data-action="save-child"]') as HTMLButtonElement).click() await flushPromises() const [put] = requestsTo(calls, 'PUT', `${RECORDS}/${first.id}`) expect(await put!.clone().json()).toEqual({ name: 'Renamed' }) expect(put!.headers.get('X-Child-Session-Key')).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull() }) it('closes, toasts and reloads when the child is gone', async () => { const first = linkedFixture.all.data[0]! const { calls } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable }, [`GET ${RECORDS}/${first.id}`]: failure(404, 'Not found'), }) ;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click() await flushPromises() expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull() expect(toasts.value[0]).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' }) expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2) }) it('deletes the selected children after a confirmation and keeps them on failure', async () => { const { wrapper, calls } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable }, [`POST ${DELETE}`]: failure(422, 'Refused'), }) expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined() await wrapper.find('tbody [role="checkbox"]').trigger('click') await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click() await flushPromises() expect(requestsTo(calls, 'POST', DELETE)).toHaveLength(1) expect(toasts.value[0]).toMatchObject({ text: 'Refused', tone: 'danger' }) 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 = {}) { const schema = clone(members) schema.data.view.toolbarButtons = buttons Object.assign(schema.data, extra) return { body: schema } } function session(recordId: number): FormSession & { markDirty: ReturnType } { 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('[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() }) })