test(12.1-05): unit tests for the list, form, preview and routing behaviours of Phase 12.1 in the SPA

- 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
This commit is contained in:
Jakub Zych
2026-10-05 15:36:00 +02:00
parent 84efdc09e0
commit aced6c7df3
10 changed files with 1251 additions and 8 deletions

View File

@@ -1,7 +1,8 @@
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, optionsFixture, recordFixture } from '../fixtures/typed'
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' }
@@ -241,3 +242,123 @@ describe('multiple relation (D-18)', () => {
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]])
})
})