- 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
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
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)
|
||
})
|
||
})
|