- RelationManager renders create/link/delete/unlink in declared order with one primary, opens the child modal (update or view form) or the pivot modal on row click, and deletes selected children behind a busy confirm - RelationChildModal creates and edits children with its own session key (X-Child-Session-Key) so uploads and dates work inside it - RelationPivotModal edits link details; the picker links one record with its pivot values when the relation has a pivot form - deferrable managers render on the create screen with owner id 0, the form's X-Session-Key and the pending note, and mark the form dirty - the registry resolves RelationManager lazily (child forms close an import cycle); DataTable gains openable rows and a trailing cell - relation lang keys in en and pl; rebuilt boardwalk dist
226 lines
9.4 KiB
TypeScript
226 lines
9.4 KiB
TypeScript
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<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${CANDIDATES}`]: (request) => ({
|
|
body: queryOf(request).get('page') === '2' ? candidatesFixture.page2 : candidatesFixture.page1,
|
|
}),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function openModal(overrides: Record<string, Route> = {}) {
|
|
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<HTMLElement>('[data-relation-picker]')
|
|
const options = () => [...(dialog()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])]
|
|
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[data-page="prev"]')!.disabled).toBe(true)
|
|
await click(dialog()!.querySelector('[data-page="next"]'))
|
|
expect(dialog()!.querySelector<HTMLButtonElement>('[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<string, Route> = {}) {
|
|
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<HTMLButtonElement>('[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<HTMLInputElement>('#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()
|
|
})
|
|
})
|