- list: the bulk menu in the toolbar and on the list view with its three failure rows, row states and invisible columns in the table - form: the forbidden banner, password and preset on the form view, locked relation options, the save body of password and permission fields - preview view: context fields, hidden tabs, the status hint, the footer with zero, one and several actions, load failures - routing: the preview route and mapWinterUrl - backstops: focus returns to the bulk menu trigger; preview URL mapping and the route replacement; locked relation options - the slug preset runs on the table the user plugin's slug test uses
365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
|
import { formField, langFixture, 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')
|
|
})
|
|
})
|
|
|
|
describe('locked relation options (UI-SPEC S6, D-07)', () => {
|
|
const NOTE = 'Items marked with a lock need an additional permission to change.'
|
|
const strings = {
|
|
'backend::lang.form.locked_item': { other: 'Locked: :name' },
|
|
'backend::lang.form.locked_note': { other: NOTE },
|
|
'backend::lang.form.remove_item': { other: 'Remove: :name' },
|
|
}
|
|
// Round (3) is a locked value; Large (4) is a locked choice; Extra (7) is free.
|
|
const lockedLabels = [
|
|
{ value: 3, label: 'Round', locked: true },
|
|
{ value: 1, label: 'Small' },
|
|
]
|
|
const lockedOptions = {
|
|
data: [
|
|
{ value: 1, label: 'Small' },
|
|
{ value: 3, label: 'Round', locked: true },
|
|
{ value: 4, label: 'Large', locked: true },
|
|
{ value: 7, label: 'Extra' },
|
|
],
|
|
meta: { page: 1, per_page: 20, total: 4, last_page: 1 },
|
|
}
|
|
const chipNames = (wrapper: VueWrapper) => wrapper.findAll('[data-chip]').map((chip) => chip.find('.font-semibold').text())
|
|
const option = (wrapper: VueWrapper, value: number) => wrapper.find(`[role="option"][data-value="${value}"]`)
|
|
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
|
|
|
|
function mountLocked(modelValue: unknown = [3, 1], labels = lockedLabels) {
|
|
mockApi(routes({ [`GET ${TAGS}`]: { body: lockedOptions } }))
|
|
return mountField('tags', modelValue, { labels })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
|
})
|
|
|
|
it('shows a locked chip with a lock, its name for screen readers and no remove button', () => {
|
|
const wrapper = mountLocked()
|
|
const [round, small] = wrapper.findAll('[data-chip]')
|
|
expect(round!.find('button').exists()).toBe(false)
|
|
expect(round!.find('[data-chip-locked] svg').attributes('aria-hidden')).toBe('true')
|
|
expect(round!.find('[data-chip-locked] .sr-only').text()).toBe('Locked: Round')
|
|
expect(small!.find('[data-chip-locked]').exists()).toBe(false)
|
|
expect(small!.find('button').attributes('aria-label')).toBe('Remove: Small')
|
|
})
|
|
|
|
it('shows the note once a locked label or option was seen, and names it in aria-describedby', async () => {
|
|
const wrapper = mountLocked()
|
|
const note = wrapper.find('[data-locked-note]')
|
|
expect(note.text()).toBe(NOTE)
|
|
expect(combobox(wrapper).attributes('aria-describedby')).toContain(note.attributes('id'))
|
|
|
|
// No locked label: no note until an options page brings a locked choice.
|
|
const plain = mountLocked([1], [{ value: 1, label: 'Small' }])
|
|
expect(plain.find('[data-locked-note]').exists()).toBe(false)
|
|
await openList(plain)
|
|
expect(plain.find('[data-locked-note]').text()).toBe(NOTE)
|
|
})
|
|
|
|
it('has no note and no lock without locked items', async () => {
|
|
mockApi(routes())
|
|
const wrapper = mountField('tags', [3, 1])
|
|
await openList(wrapper)
|
|
expect(wrapper.find('[data-locked-note]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-chip-locked]').exists()).toBe(false)
|
|
expect(wrapper.findAll('[role="option"]').every((item) => item.attributes('aria-disabled') === undefined)).toBe(true)
|
|
})
|
|
|
|
it('backstop: a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace', async () => {
|
|
const wrapper = mountLocked()
|
|
await openList(wrapper)
|
|
|
|
// The list offers the unselected choices: Large is locked, Extra is free.
|
|
expect(optionTexts(wrapper)).toEqual(['Large', 'Extra'])
|
|
const large = option(wrapper, 4)
|
|
expect(large.attributes('aria-disabled')).toBe('true')
|
|
expect(large.classes()).toEqual(expect.arrayContaining(['text-muted', 'cursor-not-allowed']))
|
|
expect(large.find('svg').exists()).toBe(true)
|
|
expect(option(wrapper, 7).attributes('aria-disabled')).toBeUndefined()
|
|
|
|
// By click.
|
|
await large.trigger('mousedown')
|
|
await large.trigger('click')
|
|
expect(emitted(wrapper)).toEqual([])
|
|
|
|
// By the arrow keys: the active option is never the locked one, in
|
|
// either direction and however often the key is pressed.
|
|
const search = combobox(wrapper)
|
|
for (const key of ['ArrowDown', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp', 'Home', 'End']) {
|
|
keydown(search.element, key)
|
|
await flushPromises()
|
|
expect(search.attributes('aria-activedescendant')).not.toBe(large.attributes('id'))
|
|
}
|
|
// By Enter: whatever is active, the locked id is not what gets added.
|
|
keydown(search.element, 'Enter')
|
|
await flushPromises()
|
|
for (const value of emitted(wrapper)) {
|
|
expect(value).not.toContain(4)
|
|
}
|
|
expect(emitted(wrapper)).toEqual([[3, 1, 7]])
|
|
|
|
// A locked chip has nothing to click, and Backspace removes only
|
|
// unlocked chips: with the locked one left it does nothing.
|
|
const chips = mountLocked([3, 1])
|
|
expect(chips.findAll('[data-chip]')[0]!.find('button').exists()).toBe(false)
|
|
await chips.findAll('[data-chip]')[0]!.trigger('click')
|
|
expect(emitted(chips)).toEqual([])
|
|
keydown(combobox(chips).element, 'Backspace')
|
|
expect(emitted(chips)).toEqual([[3]])
|
|
await chips.setProps({ modelValue: [3] })
|
|
keydown(combobox(chips).element, 'Backspace')
|
|
keydown(combobox(chips).element, 'Delete')
|
|
expect(emitted(chips)).toEqual([[3]])
|
|
expect(chipNames(chips)).toEqual(['Round'])
|
|
|
|
// A locked chip in the last position is skipped, not removed.
|
|
const tail = mountLocked([1, 3])
|
|
keydown(combobox(tail).element, 'Backspace')
|
|
expect(emitted(tail)).toEqual([[3]])
|
|
})
|
|
})
|