import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { clearUser } from '../../src/state/useAuth' import { setNavigation } from '../../src/state/useNavigation' import { setSettings } from '../../src/state/useSettings' import { clearToasts } from '../../src/state/useToasts' import lang from '../fixtures/lang.json' import formSchema from '../fixtures/widgets.form-schema.json' import record from '../fixtures/widgets.record.json' import relationSchema from '../fixtures/widgets.relation-schema.json' import linked from '../fixtures/widgets.relation-linked.json' import candidates from '../fixtures/widgets.relation-candidates.json' import { API, mountApp, pathOf, queryOf, requestsTo, type Route } from '../helpers' const BASE = `${API}/acme/demo/widgets` const RECORD = `${BASE}/1` const SCHEMA = `${BASE}/schema/relation/members` const LINKED = `${RECORD}/relations/members` const CANDIDATES = `${LINKED}/candidates` const LINK = `${LINKED}/link` const UNLINK = `${LINKED}/unlink` function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/form`]: { body: formSchema }, [`GET ${RECORD}`]: { body: record }, [`GET ${SCHEMA}`]: { body: relationSchema }, [`GET ${LINKED}`]: (request) => ({ body: queryOf(request).get('search') === 'max' ? linked.search : linked.all, }), [`GET ${CANDIDATES}`]: (request) => { const query = queryOf(request) if (query.get('search') === 'noether') { return { body: candidates.search } } return { body: query.get('page') === '2' ? candidates.page2 : candidates.page1 } }, [`POST ${LINK}`]: async (request) => ({ body: { data: { linked: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} }, }), [`POST ${UNLINK}`]: async (request) => ({ body: { data: { removed: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} }, }), ...overrides, } } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function openMembers(wrapper: VueWrapper): Promise { const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === 'Members') expect(tab).toBeDefined() await tab!.trigger('click') await flushPromises() } function manager(wrapper: VueWrapper) { return wrapper.find('[data-relation-manager]') } function rowNames(wrapper: VueWrapper): string[] { return manager(wrapper) .findAll('tbody tr') .map((tr) => { const cell = tr.findAll('td:not([data-select])')[0] const avatar = cell?.find('[data-avatar]') const initials = avatar?.exists() ? avatar.text() : '' return `${initials} ${(cell?.text() ?? '').slice(initials.length)}`.trim() }) } function picker(): HTMLElement | null { return document.body.querySelector('[data-relation-picker]') } function options(): HTMLElement[] { return [...(picker()?.querySelectorAll('[role="option"]') ?? [])] } function optionNames(): string[] { return options().map((option) => option.querySelector('.font-semibold')?.textContent?.trim() ?? '') } function confirmButton(): HTMLButtonElement { return picker()!.querySelector('[data-action="confirm-link"]') as HTMLButtonElement } async function click(element: Element | null | undefined): Promise { ;(element as HTMLElement).click() await flushPromises() } /** Opens the picker the way a keyboard user does: focus, then activate. */ async function openPicker(wrapper: VueWrapper): Promise { const button = manager(wrapper).find('[data-action="link"]').element as HTMLElement button.focus() await click(button) await wait(0) await flushPromises() return button } function confirmDialog(): HTMLElement | null { return document.body.querySelector('[data-confirm-dialog]') } beforeEach(() => { clearUser() setNavigation([]) setSettings([]) clearToasts() setBundle(lang.data, 'pl') }) afterEach(() => { document.body.innerHTML = '' }) describe('relation manager field', () => { it('is absent on create, even without context: update, and never requests relation data', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes()) expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['Podstawowe', 'Details']) expect(wrapper.find('[data-relation-manager]').exists()).toBe(false) expect(calls.some((request) => pathOf(request).includes('/relations/') || pathOf(request).includes('/schema/relation/'))).toBe(false) }) it('lists the linked rows with the view columns, the label, comment and toolbar in declared order', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) await openMembers(wrapper) const section = manager(wrapper) expect(section.find('h2').text()).toBe('Members') expect(section.text()).toContain('People who may edit this widget') expect(section.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'E-mail']) expect(rowNames(wrapper)).toEqual(['AB Ada Byron', 'MP Max Planck', 'LM Lise Meitner']) expect(section.find('tbody tr [data-avatar]').text()).toBe('AB') expect(section.findAll('tbody tr')[0]!.classes()).toContain('h-[56px]') expect(section.findAll('[data-action]').map((button) => button.attributes('data-action'))).toEqual(['link', 'unlink']) expect(section.find('[data-action="link"]').text()).toBe('Add member') expect(section.find('[data-action="unlink"]').text()).toBe('Remove selected') expect(section.find('[data-action="unlink"]').attributes('disabled')).toBeDefined() expect(requestsTo(calls, 'GET', SCHEMA)).toHaveLength(1) expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1) // The save body never carries the relation manager. expect(wrapper.find('[data-unsupported-field]').exists()).toBe(false) }) it('searches the linked rows after a 300 ms debounce', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) await openMembers(wrapper) await manager(wrapper).find('[data-relation-search]').setValue('max') await wait(100) expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1) await wait(300) await flushPromises() const searches = requestsTo(calls, 'GET', LINKED) expect(searches).toHaveLength(2) expect(queryOf(searches[1]!).get('search')).toBe('max') expect(queryOf(searches[1]!).get('page')).toBe('1') expect(rowNames(wrapper)).toEqual(['MP Max Planck']) }) it('shows the relation empty message when nothing is linked', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${LINKED}`]: { body: linked.empty } })) await openMembers(wrapper) expect(manager(wrapper).find('[data-relation-empty]').text()).toBe('This widget has no members.') }) it('confirms with the plural unlinkConfirm, POSTs unlink with the ids, reloads and toasts', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) const rows = manager(wrapper).findAll('tbody tr') await rows[0]!.find('[role="checkbox"]').trigger('click') await rows[2]!.find('[role="checkbox"]').trigger('click') const unlink = manager(wrapper).find('[data-action="unlink"]') expect(unlink.attributes('disabled')).toBeUndefined() await unlink.trigger('click') await flushPromises() expect(confirmDialog()!.textContent).toContain('Remove 2 members?') await click(confirmDialog()!.querySelector('[data-action="confirm"]')) const [post] = requestsTo(calls, 'POST', UNLINK) expect(post).toBeDefined() expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(await post!.json()).toEqual({ ids: [21, 23] }) expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2) expect(wrapper.find('[role="status"]').text()).toContain('Removed 2 members') expect(manager(wrapper).find('[data-action="unlink"]').attributes('disabled')).toBeDefined() }) it('sends nothing when the unlink confirmation is cancelled', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) await manager(wrapper).findAll('tbody tr')[1]!.find('[role="checkbox"]').trigger('click') await manager(wrapper).find('[data-action="unlink"]').trigger('click') await flushPromises() expect(confirmDialog()!.textContent).toContain('Remove 1 member?') await click(confirmDialog()!.querySelector('[data-action="cancel"]')) expect(requestsTo(calls, 'POST', UNLINK)).toHaveLength(0) }) }) describe('relation picker modal', () => { it('opens an aria-modal dialog with the search focused and five candidates per page', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) await openPicker(wrapper) const dialog = picker()! expect(dialog.getAttribute('role')).toBe('dialog') expect(dialog.getAttribute('aria-modal')).toBe('true') expect(dialog.textContent).toContain('Add member') expect(dialog.textContent).toContain('Pick people who are not members yet. The owner is not listed.') const search = dialog.querySelector('[data-candidate-search]')! expect(search.placeholder).toBe('Search by name or email…') expect(document.activeElement).toBe(search) const listbox = dialog.querySelector('[role="listbox"]')! expect(listbox.getAttribute('aria-multiselectable')).toBe('true') const [first] = requestsTo(calls, 'GET', CANDIDATES) expect(queryOf(first!).get('per_page')).toBe('5') expect(queryOf(first!).get('page')).toBe('1') expect(optionNames()).toEqual(['Alan Turing', 'Grace Hopper', 'Emmy Noether', 'Niels Bohr', 'Marie Curie']) // Rendered exactly as served: the SPA does not filter candidates. expect(options()).toHaveLength(candidates.page1.data.length) expect(dialog.querySelector('[data-candidates-range]')!.textContent).toBe('1–5 z 7') expect(confirmButton().disabled).toBe(true) expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)') }) it('keeps the selection across pages and POSTs link with every selected id', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) const opener = await openPicker(wrapper) await click(options()[0]) await click(options()[1]) expect(options()[0]!.getAttribute('aria-selected')).toBe('true') expect(options()[2]!.getAttribute('aria-selected')).toBe('false') expect(confirmButton().textContent?.trim()).toBe('Dodaj (2)') await click(picker()!.querySelector('[data-page="next"]')) const pages = requestsTo(calls, 'GET', CANDIDATES) expect(queryOf(pages[pages.length - 1]!).get('page')).toBe('2') expect(optionNames()).toEqual(['Carl Gauss', 'Rosalind Franklin']) expect(picker()!.querySelector('[data-candidates-range]')!.textContent).toBe('6–7 z 7') // Keyboard selection with Space. options()[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })) await flushPromises() expect(confirmButton().textContent?.trim()).toBe('Dodaj (3)') await click(picker()!.querySelector('[data-page="prev"]')) expect(options()[0]!.getAttribute('aria-selected')).toBe('true') expect(options()[1]!.getAttribute('aria-selected')).toBe('true') await click(confirmButton()) await wait(0) await flushPromises() const [post] = requestsTo(calls, 'POST', LINK) expect(await post!.json()).toEqual({ ids: [11, 12, 17] }) expect(picker()).toBeNull() expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2) expect(wrapper.find('[role="status"]').text()).toContain('Added 3 members') expect(document.activeElement).toBe(opener) }) it('searches candidates after the debounce and resets to page 1', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) await openPicker(wrapper) await click(picker()!.querySelector('[data-page="next"]')) const search = picker()!.querySelector('[data-candidate-search]')! search.value = 'noether' search.dispatchEvent(new Event('input', { bubbles: true })) await wait(350) await flushPromises() const pages = requestsTo(calls, 'GET', CANDIDATES) const last = pages[pages.length - 1]! expect(queryOf(last).get('search')).toBe('noether') expect(queryOf(last).get('page')).toBe('1') expect(optionNames()).toEqual(['Emmy Noether']) }) it('closes on Esc without linking, returns focus to the opener and clears the selection', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) const opener = await openPicker(wrapper) await click(options()[0]) expect(confirmButton().textContent?.trim()).toBe('Dodaj (1)') document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await flushPromises() await wait(0) await flushPromises() expect(picker()).toBeNull() expect(document.activeElement).toBe(opener) expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0) await openPicker(wrapper) expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)') expect(options().every((option) => option.getAttribute('aria-selected') === 'false')).toBe(true) }) it('closes on Anuluj and returns focus to the opener', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(), { attach: true }) await openMembers(wrapper) const opener = await openPicker(wrapper) await click(picker()!.querySelector('[data-action="cancel"]')) await wait(0) await flushPromises() expect(picker()).toBeNull() expect(document.activeElement).toBe(opener) }) })