Files
summercms/admin/tests/form/RelationField.test.ts
Jakub Zych 1c2a66df45 test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
2026-09-27 17:53:57 +02:00

244 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import RelationField from '../../src/components/form/fields/RelationField.vue'
import { formField, optionsFixture, recordFixture } from '../fixtures/typed'
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const MAKER = `${API}/acme/demo/widgets/fields/maker/options`
const TAGS = `${API}/acme/demo/widgets/fields/tags/options`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${MAKER}`]: (request) => {
const query = queryOf(request)
if (query.get('search') === 'acme') {
return { body: optionsFixture.makerFieldSearch }
}
return { body: query.get('page') === '2' ? optionsFixture.makerFieldPage2 : optionsFixture.makerField }
},
[`GET ${TAGS}`]: { body: optionsFixture.tagsField },
...overrides,
}
}
function mountField(name: string, modelValue: unknown, extra: Record<string, unknown> = {}) {
return mount(RelationField, {
props: {
field: formField(name),
modelValue,
controlId: `r-${name}`,
labels: recordFixture.meta.labels[name],
source,
...extra,
},
attachTo: document.body,
})
}
const combobox = (wrapper: VueWrapper) => wrapper.find('[role="combobox"]')
const optionTexts = (wrapper: VueWrapper) => wrapper.findAll('[role="option"]').map((option) => option.text())
async function openList(wrapper: VueWrapper) {
await combobox(wrapper).trigger('click')
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('read-only relation (D-26)', () => {
it('shows only the label from meta.labels, with no control', () => {
const wrapper = mountField('owner', 5, { describedBy: 'r-owner-comment' })
const box = wrapper.find('[data-relation-readonly]')
expect(box.text()).toBe('dana@example.test')
expect(box.attributes('id')).toBe('r-owner')
expect(box.attributes('aria-describedby')).toBe('r-owner-comment')
expect(box.classes()).toContain('text-text')
expect(wrapper.find('input').exists()).toBe(false)
})
it('falls back to the emptyOption, then a muted dash', () => {
const withEmpty = mountField('owner', null, { labels: [], field: { ...formField('owner'), emptyOption: 'Nobody' } })
expect(withEmpty.find('[data-relation-readonly]').text()).toBe('Nobody')
const bare = mountField('owner', null, { labels: undefined, field: { ...formField('owner'), emptyOption: undefined } })
expect(bare.find('[data-relation-readonly]').text()).toBe('—')
expect(bare.find('[data-relation-readonly]').classes()).toContain('text-muted')
})
})
describe('single relation (D-17)', () => {
it('is a collapsed combobox showing the current label', () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
const input = combobox(wrapper)
expect(input.attributes('aria-expanded')).toBe('false')
expect(input.attributes('aria-controls')).toBe('r-maker-listbox')
expect(input.attributes('aria-autocomplete')).toBe('list')
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('r-maker-listbox')
})
it('loads the first page of 20 options on open, emptyOption first and muted', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', 2)
await openList(wrapper)
const [request] = requestsTo(calls, 'GET', MAKER)
expect(queryOf(request!).get('page')).toBe('1')
expect(queryOf(request!).get('per_page')).toBe('20')
expect(queryOf(request!).has('search')).toBe(false)
expect(combobox(wrapper).attributes('aria-expanded')).toBe('true')
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
const options = wrapper.findAll('[role="option"]')
expect(options[0]!.attributes('data-value')).toBe('empty')
expect(options[0]!.classes()).toContain('text-muted')
expect(options.map((option) => option.attributes('aria-selected'))).toEqual(['false', 'false', 'true'])
})
it('chooses an id or null and closes', async () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
await openList(wrapper)
await wrapper.findAll('[role="option"]')[1]!.trigger('mousedown')
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
await openList(wrapper)
await wrapper.findAll('[role="option"]')[0]!.trigger('mousedown')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([null])
})
it('moves the active option with the arrows, exposes it as aria-activedescendant and picks it with Enter', async () => {
mockApi(routes())
const wrapper = mountField('maker', null)
const input = combobox(wrapper).element
expect(keydown(input, 'ArrowDown').defaultPrevented).toBe(true)
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBeUndefined()
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-2')
keydown(input, 'ArrowUp')
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-1')
expect(keydown(input, 'Enter').defaultPrevented).toBe(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
})
it('closes on Escape and blur, and ignores Enter and Escape while closed', async () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
const input = combobox(wrapper).element
expect(keydown(input, 'Enter').defaultPrevented).toBe(false)
expect(keydown(input, 'Escape').defaultPrevented).toBe(false)
await openList(wrapper)
expect(keydown(input, 'Escape').defaultPrevented).toBe(true)
await flushPromises()
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
await openList(wrapper)
await combobox(wrapper).trigger('blur')
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('searches after a 300 ms debounce and loads more on demand', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null)
await openList(wrapper)
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(true)
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
await flushPromises()
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('page')).toBe('2')
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
await combobox(wrapper).setValue('acme')
await wait(150)
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
await wait(200)
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('search')).toBe('acme')
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works'])
})
it('loads more when the list is scrolled to its end', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null)
await openList(wrapper)
const list = wrapper.find('[role="listbox"]').element as HTMLElement
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 })
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 256 })
list.scrollTop = 10
list.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(1)
list.scrollTop = 140
list.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
})
it('shows "no options" for an empty or failed options load', async () => {
mockApi(routes({ [`GET ${MAKER}`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }))
const wrapper = mountField('maker', null)
await openList(wrapper)
expect(optionTexts(wrapper)).toEqual(['— none —'])
expect(wrapper.find('[role="listbox"]').text()).toContain('Brak wyników')
})
it('never requests options without a source controller', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null, { source: null })
await openList(wrapper)
expect(calls).toHaveLength(0)
})
})
describe('multiple relation (D-18)', () => {
it('shows ordered chips with initials and labelled remove buttons', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, 1])
const chips = wrapper.findAll('[data-chip]')
expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['Round', 'Small'])
expect(chips[0]!.find('[aria-hidden="true"]').text()).toBe('R')
expect(chips.map((chip) => chip.find('button').attributes('aria-label'))).toEqual(['Usuń: Round', 'Usuń: Small'])
await chips[0]!.find('button').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]])
})
it('offers only unselected options and appends the chosen id', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, 1])
await openList(wrapper)
expect(optionTexts(wrapper)).toEqual(['Large'])
expect(wrapper.find('[role="option"]').attributes('aria-selected')).toBe('false')
await wrapper.find('[role="option"]').trigger('mousedown')
expect(wrapper.emitted('update:modelValue')).toEqual([[[3, 1, 4]]])
})
it('removes the last chip on Backspace with an empty query', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, '1', 'x'])
keydown(combobox(wrapper).element, 'Backspace')
expect(wrapper.emitted('update:modelValue')).toEqual([[[3]]])
const empty = mountField('tags', [])
keydown(combobox(empty).element, 'Backspace')
expect(empty.emitted('update:modelValue')).toBeUndefined()
})
it('labels an unknown id with the id itself', () => {
mockApi(routes())
const wrapper = mountField('tags', [99], { labels: [] })
expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99')
})
})