Files
summercms/admin/tests/smoke/relation.smoke.test.ts
Jakub Zych f4e97cccad feat(10-04): search, link and unlink related records through the relation manager
- relation-manager registered in the field registry; renders only on an
  existing record, never on create, and is never part of the save body
- RelationManager: relation schema label and comment, debounced search,
  selectable linked list (DataTable relation variant), toolbar buttons in
  declared order, confirmed unlink with plural messages and toasts
- RelationPickerModal: Reka Dialog (aria-modal, focus trap, Esc) over the
  candidates endpoint five per page, selection kept across pages, Dodaj (N)
  POSTs link, focus returns to the opener
- admin OpenAPI documents search, sort, dir, page and per_page on the linked
  and candidate relation routes so the SPA sends them typed
- neutral acme.demo.widgets members fixtures and relation smoke tests
2026-09-27 17:18:14 +02:00

330 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Route> = {}): Record<string, Route> {
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<void> {
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<HTMLElement>('[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<void> {
;(element as HTMLElement).click()
await flushPromises()
}
/** Opens the picker the way a keyboard user does: focus, then activate. */
async function openPicker(wrapper: VueWrapper): Promise<HTMLElement> {
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<HTMLInputElement>('[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<HTMLInputElement>('[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)
})
})