import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createMemoryHistory } from 'vue-router' import RelationManager from '../../src/components/relation/RelationManager.vue' import { createAdminRouter } from '../../src/app/router' import { toasts } from '../../src/state/useToasts' import { clone, 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 = {}) { 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())] }, 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') }) })