import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import RelationPickerModal from '../../src/components/relation/RelationPickerModal.vue' import { toasts } from '../../src/state/useToasts' import { candidatesFixture, relationSchemaFixture } from '../fixtures/typed' import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers' const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } const CANDIDATES = `${API}/acme/demo/widgets/1/relations/members/candidates` const LINK = `${API}/acme/demo/widgets/1/relations/members/link` const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } }) function routes(overrides: Record = {}): Record { return { [`GET ${CANDIDATES}`]: (request) => ({ body: queryOf(request).get('page') === '2' ? candidatesFixture.page2 : candidatesFixture.page1, }), ...overrides, } } async function openModal(overrides: Record = {}) { const calls = mockApi(routes(overrides)) const wrapper = mount(RelationPickerModal, { props: { open: true, source, recordId: 1, relation: 'members', schema: relationSchemaFixture.data }, attachTo: document.body, }) await flushPromises() await wait(0) await flushPromises() return { wrapper, calls } } const dialog = () => document.body.querySelector('[data-relation-picker]') const options = () => [...(dialog()?.querySelectorAll('[role="option"]') ?? [])] const confirmButton = () => dialog()!.querySelector('[data-action="confirm-link"]')! async function click(element: Element | null | undefined) { ;(element as HTMLElement).click() await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('relation picker modal', () => { it('is a titled, described modal dialog with a labelled multi-select listbox', async () => { await openModal() const element = dialog()! expect(element.getAttribute('role')).toBe('dialog') expect(element.getAttribute('aria-modal')).toBe('true') const title = document.getElementById(element.getAttribute('aria-labelledby')!) expect(title?.textContent).toBe('Add member') const description = document.getElementById(element.getAttribute('aria-describedby')!) expect(description?.textContent).toContain('The owner is not listed.') const listbox = element.querySelector('[role="listbox"]')! expect(listbox.getAttribute('aria-multiselectable')).toBe('true') expect(listbox.getAttribute('aria-label')).toBe('Add member') expect(element.querySelector('[data-candidate-search]')!.getAttribute('aria-controls')).toBe(listbox.id) expect(element.querySelector('[data-action="close"]')!.getAttribute('aria-label')).toBe('Zamknij') expect(options().every((option) => option.getAttribute('tabindex') === '0')).toBe(true) }) it('shows initials and both columns for each candidate as served', async () => { await openModal() const first = options()[0]! expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe('AT') expect(first.textContent).toContain('alan.turing@example.test') }) it('disables prev on the first page and next on the last', async () => { await openModal() expect(dialog()!.querySelector('[data-page="prev"]')!.disabled).toBe(true) await click(dialog()!.querySelector('[data-page="next"]')) expect(dialog()!.querySelector('[data-page="next"]')!.disabled).toBe(true) }) it('toggles with Enter and moves between options with the arrow keys', async () => { await openModal() const [first, second] = options() first!.focus() expect(keydown(first!, 'Enter').defaultPrevented).toBe(true) await flushPromises() expect(options()[0]!.getAttribute('aria-selected')).toBe('true') keydown(first!, 'ArrowDown') expect(document.activeElement).toBe(second) keydown(second!, 'ArrowUp') expect(document.activeElement).toBe(first) keydown(first!, 'ArrowUp') expect(document.activeElement).toBe(first) keydown(first!, 'Enter') await flushPromises() expect(options()[0]!.getAttribute('aria-selected')).toBe('false') expect(keydown(first!, 'x').defaultPrevented).toBe(false) }) it('says so when no candidate is left', async () => { await openModal({ [`GET ${CANDIDATES}`]: { body: candidatesFixture.empty } }) expect(dialog()!.querySelector('[data-candidates-empty]')!.textContent?.trim()).toBe('Brak rekordów do dodania.') expect(dialog()!.querySelector('nav')).toBeNull() }) it('treats a failed candidates load as empty', async () => { await openModal({ [`GET ${CANDIDATES}`]: failure(500, 'Boom') }) expect(options()).toHaveLength(0) expect(dialog()!.querySelector('[data-candidates-empty]')).not.toBeNull() }) it('toasts the server message when the link is refused and stays open', async () => { const { wrapper, calls } = await openModal({ [`POST ${LINK}`]: failure(422, 'Already a member') }) await click(options()[0]) await click(confirmButton()) expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1) expect(toasts.value[0]).toMatchObject({ text: 'Already a member', tone: 'danger' }) expect(dialog()).not.toBeNull() expect(wrapper.emitted('linked')).toBeUndefined() }) it('toasts the generic error when the link cannot reach the server', async () => { await openModal({ [`POST ${LINK}`]: () => { throw new TypeError('offline') }, }) await click(options()[1]) await click(confirmButton()) expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' }) expect(confirmButton().disabled).toBe(false) }) it('emits the linked count and closes after a successful link', async () => { const { wrapper } = await openModal({ [`POST ${LINK}`]: { body: { data: {}, meta: {} } } }) await click(options()[0]) await click(options()[2]) await click(confirmButton()) expect(wrapper.emitted('update:open')).toEqual([[false]]) // Without a server count the selection size is reported. expect(wrapper.emitted('linked')).toEqual([[2]]) }) it('clears the search, page and selection when it closes', async () => { const { wrapper, calls } = await openModal() await click(options()[0]) await wrapper.setProps({ open: false }) await flushPromises() await wrapper.setProps({ open: true }) await flushPromises() await wait(0) await flushPromises() expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)') expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1') }) }) describe('pivot link step (Phase 12.2, D-14)', () => { const pivotSchema = { ...relationSchemaFixture.data, pivotForm: [{ name: 'role', label: 'Role', type: 'text' }], } async function openPivot(overrides: Record = {}) { const calls = mockApi(routes(overrides)) const wrapper = mount(RelationPickerModal, { props: { open: true, source, recordId: 1, relation: 'members', schema: pivotSchema }, attachTo: document.body, }) await flushPromises() await wait(0) await flushPromises() return { wrapper, calls } } const nextButton = () => dialog()!.querySelector('[data-action="next"]')! it('selects one record, asks for its pivot values and links it with them', async () => { const { wrapper, calls } = await openPivot({ [`POST ${LINK}`]: { body: { data: { linked: 1 }, meta: {} } } }) expect(dialog()!.querySelector('[role="listbox"]')!.getAttribute('aria-multiselectable')).toBe('false') expect(nextButton().disabled).toBe(true) await click(options()[0]) await click(options()[1]) expect(options().filter((option) => option.getAttribute('aria-selected') === 'true')).toHaveLength(1) await click(nextButton()) expect(dialog()!.querySelector('[data-pivot-step]')).not.toBeNull() const role = dialog()!.querySelector('#link-members-role')! role.value = 'editor' role.dispatchEvent(new Event('input')) await flushPromises() await click(confirmButton()) const [post] = requestsTo(calls, 'POST', LINK) expect(await post!.clone().json()).toEqual({ ids: [candidatesFixture.page1.data[1]!.id], pivot: { role: 'editor' } }) expect(wrapper.emitted('linked')).toEqual([[1]]) }) it('goes back to the list with the selection kept', async () => { await openPivot() await click(options()[1]) await click(nextButton()) await click(dialog()!.querySelector('[data-action="back"]')) expect(dialog()!.querySelector('[data-pivot-step]')).toBeNull() expect(options()[1]!.getAttribute('aria-selected')).toBe('true') }) it('keeps pivot errors in the dialog on a 422', async () => { const { wrapper } = await openPivot({ [`POST ${LINK}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { role: ['The role is invalid.'] } } }, }, }) await click(options()[0]) await click(nextButton()) await click(confirmButton()) expect(dialog()!.querySelector('[data-pivot-step]')!.textContent).toContain('The role is invalid.') expect(wrapper.emitted('update:open')).toBeUndefined() }) })