test(12.1-05): unit tests for the Phase 12.1 SPA components

- BulkActionsMenu, RowStateBadges, RecordActions, PreviewField, FormErrorBanner, PasswordField and PermissionEditorField, each mounted on its own
- backstop: a row state outside the fixed set renders no badge and no class
- backstop: the permission editor's emission rules per mode, the locked row and codes outside the options
This commit is contained in:
Jakub Zych
2026-10-05 15:29:44 +02:00
parent 2e94cbf9f8
commit 84efdc09e0
7 changed files with 939 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
// The form's error banner: the 422 summary and the forbidden save banner
// (UI-SPEC S6, D-27). No application names appear in framework tests.
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.form.error_title': { other: 'The record could not be saved.' },
'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' },
}
const fieldNames = ['name', 'email']
function banner(props: { errors?: Record<string, string[]>; forbidden?: string | null }) {
return mount(FormErrorBanner, { props: { errors: props.errors ?? {}, fieldNames, forbidden: props.forbidden } })
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
describe('form error banner', () => {
it('renders nothing without errors and without a refusal', () => {
const wrapper = banner({})
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('counts the 422 fields and lists messages of keys that are not fields', () => {
const wrapper = banner({ errors: { name: ['Too short.'], record: ['The record is locked.', 'Try later.'] } })
const alert = wrapper.find('[data-error-banner]')
expect(alert.attributes('role')).toBe('alert')
expect(alert.text()).toContain('The record could not be saved.')
expect(alert.text()).toContain('Correct 2 fields.')
expect(alert.text()).toContain('The record is locked.')
expect(alert.text()).toContain('Try later.')
// A field's own message is shown on the field, not here.
expect(alert.text()).not.toContain('Too short.')
expect(banner({ errors: { name: ['x'] } }).text()).toContain('Correct 1 field.')
})
})
describe('forbidden save banner (UI-SPEC S6, D-27)', () => {
it('shows the refusal as a persistent alert in place of the 422 banner', () => {
const wrapper = banner({ forbidden: 'You may not change this group.', errors: { name: ['Reserved.'] } })
const alert = wrapper.find('[data-forbidden-banner]')
expect(alert.attributes('role')).toBe('alert')
expect(alert.find('p').text()).toBe('You may not change this group.')
expect(alert.find('svg').attributes('aria-hidden')).toBe('true')
expect(alert.classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger']))
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
// Field messages stay on their fields; no count line here.
expect(alert.text()).not.toContain('Reserved.')
expect(alert.text()).not.toContain('Correct')
// No close button: it stays until the next save attempt.
expect(alert.find('button').exists()).toBe(false)
})
it('lists details of keys that are not form fields inside the banner', () => {
const wrapper = banner({ forbidden: 'Refused.', errors: { groups: ['The admin group is locked.'], email: ['On the field.'] } })
const lines = wrapper.findAll('[data-forbidden-banner] p').map((line) => line.text())
expect(lines).toEqual(['Refused.', 'The admin group is locked.'])
})
it('wraps a long message and renders markup as text', () => {
const long = 'A very long refusal with a_long_unbroken_identifier_that_must_wrap '.repeat(6).trim()
const wrapper = banner({ forbidden: `${long} <b>x</b>` })
const text = wrapper.find('[data-forbidden-banner] p')
expect(text.classes()).toContain('[overflow-wrap:anywhere]')
expect(text.text()).toContain('<b>x</b>')
expect(wrapper.find('b').exists()).toBe(false)
expect(wrapper.html()).not.toContain('truncate')
})
it('falls back to the 422 banner when the refusal is cleared', async () => {
const wrapper = banner({ forbidden: 'Refused.', errors: { name: ['x'] } })
await wrapper.setProps({ forbidden: null })
expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false)
expect(wrapper.find('[data-error-banner]').exists()).toBe(true)
await wrapper.setProps({ errors: {} })
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
// An empty string is not a refusal.
await wrapper.setProps({ forbidden: '' })
expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,116 @@
// Password control (UI-SPEC S7, D-19), the component on its own. No
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.form.show_password': { other: 'Show password' },
'backend::lang.form.hide_password': { other: 'Hide password' },
}
function mountField(props: Record<string, unknown> = {}, revision = ref(0)) {
const field = { name: 'password', type: 'password', label: 'Password', ...(props.field as object) } as FormField
const wrapper = mount(PasswordField, {
props: { modelValue: '', controlId: 'f-password', ...props, field },
global: { provide: { [FORM_SESSION as symbol]: { revision } as unknown as FormSession } },
attachTo: document.body,
})
return { wrapper, revision }
}
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
const toggle = (wrapper: VueWrapper) => wrapper.find('[data-password-toggle]')
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('password field component (UI-SPEC S7, D-19)', () => {
it('is an empty, masked input that asks the browser for a new password', () => {
const { wrapper } = mountField()
expect(input(wrapper).attributes('type')).toBe('password')
expect(input(wrapper).element.value).toBe('')
expect(input(wrapper).attributes('id')).toBe('f-password')
expect(input(wrapper).attributes('name')).toBe('password')
expect(input(wrapper).attributes('autocomplete')).toBe('new-password')
expect(input(wrapper).attributes('spellcheck')).toBe('false')
})
it('never shows a value that is not a string', () => {
for (const modelValue of [null, undefined, 12345, { hash: '$2a$10$abc' }, ['x']]) {
const { wrapper } = mountField({ modelValue })
expect(input(wrapper).element.value).toBe('')
wrapper.unmount()
}
})
it('emits what is typed and does not keep a copy of its own', async () => {
const { wrapper } = mountField()
await input(wrapper).setValue('s3cret-pass')
expect(wrapper.emitted('update:modelValue')).toEqual([['s3cret-pass']])
// The control is driven by modelValue: the host clears it after a save.
await wrapper.setProps({ modelValue: 's3cret-pass' })
await wrapper.setProps({ modelValue: '' })
expect(input(wrapper).element.value).toBe('')
})
it('toggles between hidden and visible text and says so with aria-pressed', async () => {
const { wrapper } = mountField({ modelValue: 'visible-text' })
expect(toggle(wrapper).attributes('type')).toBe('button')
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
expect(toggle(wrapper).attributes('aria-label')).toBe('Show password')
await toggle(wrapper).trigger('click')
expect(input(wrapper).attributes('type')).toBe('text')
expect(toggle(wrapper).attributes('aria-pressed')).toBe('true')
expect(toggle(wrapper).attributes('aria-label')).toBe('Hide password')
await toggle(wrapper).trigger('click')
expect(input(wrapper).attributes('type')).toBe('password')
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
// The toggle changes no value.
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('returns to hidden when the form session moves on (after a save)', async () => {
const { wrapper, revision } = mountField({ modelValue: 'visible-text' })
await toggle(wrapper).trigger('click')
expect(input(wrapper).attributes('type')).toBe('text')
revision.value += 1
await nextTick()
expect(input(wrapper).attributes('type')).toBe('password')
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
})
it('works without a form session', async () => {
const wrapper = mount(PasswordField, {
props: { field: { name: 'password', type: 'password' } as FormField, modelValue: '', controlId: 'x' },
})
await wrapper.find('[data-password-toggle]').trigger('click')
expect(wrapper.find('input').attributes('type')).toBe('text')
})
it('marks required and invalid, and points at its comment and error lines', () => {
const { wrapper } = mountField({ field: { required: true }, invalid: true, describedBy: 'f-password-error' })
expect(input(wrapper).attributes('required')).toBeDefined()
expect(input(wrapper).attributes('aria-required')).toBe('true')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
expect(input(wrapper).attributes('aria-describedby')).toBe('f-password-error')
expect(input(wrapper).classes()).toContain('border-danger')
const plain = mountField()
expect(input(plain.wrapper).attributes('aria-invalid')).toBeUndefined()
expect(input(plain.wrapper).attributes('required')).toBeUndefined()
})
})

View File

@@ -0,0 +1,218 @@
// Permission editor (UI-SPEC S5, D-16), the component on its own. Fixtures
// are neutral acme.roster.* data; no application names appear in framework
// tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import { permissionValues } from '../../src/components/form/control'
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
import { editablePayload } from '../../src/components/form/formState'
import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed'
import { keydown, resetState } from '../helpers'
const strings = {
'backend::lang.permissioneditor.allow': { other: 'Allow' },
'backend::lang.permissioneditor.inherit': { other: 'Inherit' },
'backend::lang.permissioneditor.deny': { other: 'Deny' },
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
}
/** The roster permissions field: Content (2), Reports (1, locked), untabbed (1). */
function field(change: Partial<FormField> = {}): FormField {
return { ...clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!), ...change }
}
function mountEditor(props: { field?: FormField; modelValue?: unknown; invalid?: boolean; describedBy?: string } = {}) {
return mount(PermissionEditorField, {
props: { field: props.field ?? field(), modelValue: props.modelValue ?? {}, controlId: 'p', invalid: props.invalid, describedBy: props.describedBy },
attachTo: document.body,
})
}
const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`)
const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`)
const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]')
const box = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[role="checkbox"]')
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('permission editor component (UI-SPEC S5, D-16)', () => {
it('groups the options into sections by tab, in the order sent, untabbed ones last as Other', () => {
const options = [
{ code: 'z.first', label: 'Z first', tab: 'Zeta' },
{ code: 'loose.one', label: 'Loose one' },
{ code: 'a.one', label: 'A one', tab: 'Alpha' },
{ code: 'z.second', label: 'Z second', tab: 'Zeta' },
{ code: 'loose.two', label: '' },
]
const wrapper = mountEditor({ field: field({ permissionOptions: options }) })
const sections = wrapper.findAll('[data-permission-section]')
expect(sections.map((section) => section.find('h3 span').text())).toEqual(['Zeta', 'Alpha', 'Other'])
expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([
['z.first', 'z.second'],
['a.one'],
['loose.one', 'loose.two'],
])
// Every section is a group named by its heading; a label falls back to the code.
expect(sections.every((section) => section.attributes('role') === 'group' && section.attributes('aria-labelledby') === section.find('h3').attributes('id'))).toBe(true)
expect(row(wrapper, 'loose.two').text()).toContain('loose.two')
})
it('has no Other section when every option has a tab', () => {
const wrapper = mountEditor({ field: field({ permissionOptions: [{ code: 'a.one', label: 'A', tab: 'Alpha' }] }) })
expect(wrapper.findAll('[data-permission-section]').map((section) => section.find('h3 span').text())).toEqual(['Alpha'])
})
it('shows the empty state without options, keeping the control id for the label', () => {
for (const permissionOptions of [[], undefined]) {
const wrapper = mountEditor({ field: field({ permissionOptions }) })
const empty = wrapper.find('[data-permission-empty]')
expect(empty.text()).toBe('No permissions are defined yet.')
expect(empty.attributes('id')).toBe('p')
expect(wrapper.find('[data-permission-editor]').exists()).toBe(false)
wrapper.unmount()
}
})
it('names the group by the field label, or by the field name without one', () => {
const labelled = mountEditor()
expect(labelled.find('[data-permission-editor]').attributes('aria-labelledby')).toBe('p-label')
expect(labelled.find('[data-permission-editor]').attributes('aria-label')).toBeUndefined()
const bare = mountEditor({ field: field({ label: '' }) })
expect(bare.find('[data-permission-editor]').attributes('aria-label')).toBe('permissions')
expect(bare.find('[data-permission-editor]').attributes('aria-labelledby')).toBeUndefined()
})
it('shows the invalid border and points at the error line', async () => {
const wrapper = mountEditor({ invalid: true, describedBy: 'p-error' })
const editor = wrapper.find('[data-permission-editor]')
expect(editor.classes()).toContain('border-danger')
expect(editor.classes()).not.toContain('border-border')
expect(editor.attributes('aria-describedby')).toBe('p-error')
await wrapper.setProps({ invalid: false })
expect(wrapper.find('[data-permission-editor]').classes()).toContain('border-border')
})
it('shows stored values: allow, deny, and inherit for a code without one', () => {
const wrapper = mountEditor({ modelValue: { 'posts.edit': 1, 'posts.publish': -1 } })
expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('radio')
expect(checked(wrapper, 'posts.edit').text()).toBe('Allow')
expect(checked(wrapper, 'posts.publish').text()).toBe('Deny')
expect(checked(wrapper, 'misc.beta').text()).toBe('Inherit')
// Values the mode does not know read as inherit.
for (const modelValue of [{ 'posts.edit': 2 }, { 'posts.edit': 'yes' }, { 'posts.edit': null }, null, 'x', [1]]) {
const odd = mountEditor({ modelValue })
expect(checked(odd, 'posts.edit').text()).toBe('Inherit')
odd.unmount()
}
})
it('shows a locked row with its stored value, a lock and its text, and a disabled control', async () => {
const wrapper = mountEditor({ modelValue: { 'reports.export': 1 } })
const locked = row(wrapper, 'reports.export')
expect(locked.attributes('data-locked')).toBe('')
expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.')
expect(locked.find('svg').attributes('aria-hidden')).toBe('true')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
const group = locked.find('[role="radiogroup"]')
expect(group.attributes('aria-disabled')).toBe('true')
expect(group.attributes('aria-describedby')).toBe(locked.find('[data-permission-locked]').attributes('id'))
// An unlocked row has neither.
expect(row(wrapper, 'posts.edit').attributes('data-locked')).toBeUndefined()
expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false)
})
it('is read-only as a whole when the field is: every control disabled, no lock shown', async () => {
for (const change of [{ readOnly: true }, { attributes: { readonly: true } }] as Partial<FormField>[]) {
const wrapper = mountEditor({ field: field(change), modelValue: { 'posts.edit': 1 } })
expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
expect(wrapper.find('[data-locked]').exists()).toBe(false)
expect(wrapper.find('[data-permission-locked]').exists()).toBe(false)
await segment(wrapper, 'posts.edit', '-1').trigger('click')
expect(emitted(wrapper)).toEqual([])
wrapper.unmount()
}
const boxes = mountEditor({ field: field({ mode: 'checkbox', readOnly: true }), modelValue: {} })
expect(boxes.findAll('[role="checkbox"]').every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
await box(boxes, 'posts.edit').trigger('click')
expect(emitted(boxes)).toEqual([])
})
it('checkbox mode: one labelled checkbox per row and an Allow column heading', () => {
const wrapper = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'posts.edit': 1, 'posts.publish': -1 } })
expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('checkbox')
expect(wrapper.find('[role="radiogroup"]').exists()).toBe(false)
expect(wrapper.findAll('[data-permission-section] h3 [aria-hidden="true"]').map((item) => item.text())).toEqual(['Allow', 'Allow', 'Allow'])
expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true')
// A stored deny is not a checkbox value: it shows unchecked.
expect(box(wrapper, 'posts.publish').attributes('aria-checked')).toBe('false')
const label = row(wrapper, 'posts.edit').find('label')
expect(label.attributes('for')).toBe(box(wrapper, 'posts.edit').attributes('id'))
expect(box(wrapper, 'reports.export').attributes('aria-disabled')).toBe('true')
})
it('backstop: radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent', async () => {
// Radio: a stored code that is not offered (legacy.code) and an
// out-of-set value are dropped from everything the control emits.
const stored = { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': 7, 'reports.export': -1 }
const radio = mountEditor({ modelValue: stored })
await segment(radio, 'posts.publish', '-1').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1 })
await radio.setProps({ modelValue: emitted(radio).at(-1) })
await segment(radio, 'misc.beta', '1').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 })
await radio.setProps({ modelValue: emitted(radio).at(-1) })
// Inherit is the absence of the code, never a 0.
await segment(radio, 'posts.edit', '0').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 })
expect(Object.values(emitted(radio).at(-1) as Record<string, number>).every((value) => value === 1 || value === -1)).toBe(true)
// A locked row cannot change: by click, and by the keyboard.
const before = emitted(radio).length
for (const value of ['1', '0', '-1']) {
await segment(radio, 'reports.export', value).trigger('click')
}
keydown(segment(radio, 'reports.export', '-1').element, 'ArrowLeft')
keydown(segment(radio, 'reports.export', '-1').element, ' ')
await flushPromises()
expect(emitted(radio)).toHaveLength(before)
expect(segment(radio, 'reports.export', '1').attributes('disabled')).toBeDefined()
// Checkbox: checked is 1, unchecked is absent; a stored deny is dropped.
const checkbox = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 } })
await box(checkbox, 'misc.beta').trigger('click')
expect(emitted(checkbox).at(-1)).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 })
await checkbox.setProps({ modelValue: emitted(checkbox).at(-1) })
await box(checkbox, 'posts.edit').trigger('click')
expect(emitted(checkbox).at(-1)).toEqual({ 'reports.export': 1, 'misc.beta': 1 })
const settled = emitted(checkbox).length
await box(checkbox, 'reports.export').trigger('click')
expect(emitted(checkbox)).toHaveLength(settled)
// The save body is reduced the same way, whatever the form state holds.
const radioField = field()
expect(permissionValues(radioField, { ...stored, 'misc.beta': 0, extra: -1 })).toEqual({ 'posts.edit': 1, 'reports.export': -1 })
expect(editablePayload([radioField], { permissions: stored }, 'update')).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': -1 } })
const checkboxField = field({ mode: 'checkbox' })
expect(editablePayload([checkboxField], { permissions: { 'posts.edit': 1, 'posts.publish': -1, 'legacy.code': 1 } }, 'update')).toEqual({
permissions: { 'posts.edit': 1 },
})
// Nothing stored still sends the field, as an empty object.
expect(editablePayload([radioField], {}, 'update')).toEqual({ permissions: {} })
// A field with no options can send no code at all.
expect(permissionValues(field({ permissionOptions: [] }), stored)).toEqual({})
})
})

View File

@@ -0,0 +1,154 @@
// One field of the preview screen (UI-SPEC S3, D-11): every row of the
// PreviewField table. Fixtures are neutral; no application names appear in
// framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import PreviewField from '../../src/components/form/PreviewField.vue'
import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.list.empty_value': { other: '—' },
'backend::lang.list.column_switch_true': { other: 'Yes' },
'backend::lang.list.column_switch_false': { other: 'No' },
'backend::lang.permissioneditor.allow': { other: 'Allow' },
'backend::lang.permissioneditor.inherit': { other: 'Inherit' },
'backend::lang.permissioneditor.deny': { other: 'Deny' },
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
}
function field(change: Partial<FormField>): FormField {
return { name: 'note', type: 'text', label: 'Note', ...change } as FormField
}
function preview(props: { field: FormField; value: unknown; labels?: { value: number; label: string }[] }) {
return mount(PreviewField, { props, attachTo: document.body })
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('preview field (UI-SPEC S3, D-11)', () => {
it('is a dt/dd pair: the label names the value and no input is rendered', () => {
const wrapper = preview({ field: field({}), value: 'Plain text' })
expect(wrapper.attributes('data-preview-field')).toBe('note')
expect(wrapper.find('dt').text()).toBe('Note')
expect(wrapper.find('dt').attributes('id')).toBe('preview-note-label')
expect(wrapper.find('dd [data-preview-value]').text()).toBe('Plain text')
expect(wrapper.find('input, textarea, select, button').exists()).toBe(false)
// Values keep full contrast: nothing is styled as disabled.
expect(wrapper.find('[data-preview-value] span').classes()).not.toContain('text-muted')
})
it('falls back to the field name without a label', () => {
expect(preview({ field: field({ label: '' }), value: 'x' }).find('dt').text()).toBe('note')
})
it.each([[null], [undefined], ['']])('shows the muted dash for an empty value (%j)', (value) => {
const wrapper = preview({ field: field({}), value })
const empty = wrapper.find('[data-preview-value] [data-empty]')
expect(empty.text()).toBe('—')
expect(empty.classes()).toContain('text-muted')
})
it('renders 0 and false-like text as values, not as empty', () => {
expect(preview({ field: field({ type: 'number' }), value: 0 }).find('[data-preview-value]').text()).toBe('0')
expect(preview({ field: field({}), value: 'false' }).find('[data-preview-value]').text()).toBe('false')
})
it('renders markup in a value as text', () => {
const wrapper = preview({ field: field({}), value: '<img src=x onerror=alert(1)>' })
expect(wrapper.find('[data-preview-value]').text()).toBe('<img src=x onerror=alert(1)>')
expect(wrapper.find('img').exists()).toBe(false)
})
it('never prints an object or an array as text', () => {
for (const value of [{ a: 1 }, [1, 2]]) {
const wrapper = preview({ field: field({}), value })
expect(wrapper.find('[data-empty]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('[object')
}
})
it('keeps the line breaks of a textarea and lets long text wrap', () => {
const wrapper = preview({ field: field({ type: 'textarea' }), value: 'one\ntwo' })
const box = wrapper.find('[data-preview-value]')
expect(box.classes()).toEqual(expect.arrayContaining(['whitespace-pre-wrap', '[overflow-wrap:anywhere]']))
expect(box.element.textContent).toContain('one\ntwo')
})
it('shows the label of a dropdown option, or the stored value when no option matches', () => {
const dropdown = field({ type: 'dropdown', options: [{ value: 'a', label: 'Alpha' }, { value: '2', label: 'Two' }] } as Partial<FormField>)
expect(preview({ field: dropdown, value: 'a' }).find('[data-preview-value]').text()).toBe('Alpha')
expect(preview({ field: dropdown, value: 2 }).find('[data-preview-value]').text()).toBe('Two')
expect(preview({ field: dropdown, value: 'gone' }).find('[data-preview-value]').text()).toBe('gone')
})
it.each([
['switch', true, 'true', 'Yes'],
['switch', 0, 'false', 'No'],
['checkbox', '1', 'true', 'Yes'],
['checkbox', null, 'false', 'No'],
])('renders a %s holding %j as a text pill', (type, value, state, text) => {
const wrapper = preview({ field: field({ type }), value })
const pill = wrapper.find('[data-switch]')
expect(pill.attributes('data-switch')).toBe(state)
expect(pill.text()).toBe(text)
expect(wrapper.find('[role="switch"], [role="checkbox"], input').exists()).toBe(false)
})
it('shows the label of a single relation from meta.labels, else the empty option or the dash', () => {
const relation = field({ type: 'relation' })
expect(preview({ field: relation, value: 2, labels: [{ value: 2, label: 'Home' }] }).find('[data-preview-value]').text()).toBe('Home')
// The id itself is never shown.
expect(preview({ field: relation, value: 2 }).find('[data-preview-value]').text()).toBe('—')
expect(preview({ field: field({ type: 'relation', emptyOption: 'No team' } as Partial<FormField>), value: null }).find('[data-empty]').text()).toBe('No team')
})
it('shows a multiple relation as chips with initials and no remove buttons', () => {
const many = field({ type: 'relation', multiple: true } as Partial<FormField>)
const wrapper = preview({
field: many,
value: [5, 6],
labels: [
{ value: 5, label: 'team leads' },
{ value: 6, label: 'news' },
],
})
const chips = wrapper.findAll('[data-chip]')
expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['team leads', 'news'])
expect(chips.map((chip) => chip.find('[aria-hidden="true"]').text())).toEqual(['TL', 'N'])
expect(wrapper.find('button').exists()).toBe(false)
expect(preview({ field: many, value: [] }).find('[data-empty]').text()).toBe('—')
// Labels without a list value are not shown.
expect(preview({ field: many, value: null, labels: [{ value: 5, label: 'x' }] }).find('[data-chip]').exists()).toBe(false)
})
it('renders a permission editor through its own control, read-only', () => {
const permissions = clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!)
const wrapper = preview({ field: permissions, value: { 'posts.edit': 1, 'reports.export': -1 } })
expect(wrapper.find('[data-permission-editor]').exists()).toBe(true)
expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
// A read-only screen shows no lock and no "you cannot change" text.
expect(wrapper.find('[data-permission-locked]').exists()).toBe(false)
expect(wrapper.find('[data-locked]').exists()).toBe(false)
// The field passed on is a copy: the schema's own field is not marked.
expect(permissions.readOnly).toBeFalsy()
})
it('uses the id prefix it is given', () => {
const wrapper = mount(PreviewField, { props: { field: field({}), value: 'x', idPrefix: 'p2' } })
expect(wrapper.find('dt').attributes('id')).toBe('p2-note-label')
})
})

View File

@@ -0,0 +1,186 @@
// Record actions (UI-SPEC S2, D-10), the component on its own. Fixtures are
// neutral acme.roster.* data; no application names appear in framework tests.
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 RecordActions from '../../src/components/form/RecordActions.vue'
import { toasts } from '../../src/state/useToasts'
import { langFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers'
const RECORD = `${API}/acme/roster/people/4`
const source = { vendor: 'acme', plugin: 'roster', controller: 'people' }
const actions = [
{ name: 'reinstate', label: 'Reinstate', confirm: 'Lift the ban on this person?' },
{ name: 'activate', label: 'Activate' },
{ name: 'markup', label: '<b>Bold</b>' },
]
const strings = {
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
'backend::lang.form.action_done': { other: 'Action completed.' },
'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' },
'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' },
'backend::lang.extension.action_failed': { other: 'The action could not be completed.' },
}
const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } })
const failure = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } })
function mountActions(routes: Record<string, Route>, props: { actions?: typeof actions; disabled?: boolean } = {}) {
const calls = mockApi(routes)
const wrapper = mount(RecordActions, {
props: { source, recordId: 4, actions: props.actions ?? actions, disabled: props.disabled ?? false },
attachTo: document.body,
})
return { wrapper, calls }
}
const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`)
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
async function run(wrapper: VueWrapper, name: string, answer: 'confirm' | 'cancel' = 'confirm'): Promise<void> {
await button(wrapper, name).trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${answer}"]`)!.click()
await flushPromises()
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('record actions component (UI-SPEC S2, D-10)', () => {
it('renders the offered actions in the given order, labels as text', () => {
const { wrapper } = mountActions({})
const buttons = wrapper.findAll('[data-record-action]')
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup'])
expect(buttons[2]!.text()).toBe('<b>Bold</b>')
expect(buttons[2]!.find('b').exists()).toBe(false)
expect(buttons.every((item) => item.classes().includes('border-border-strong'))).toBe(true)
// No primary button here: the host owns the one primary action.
expect(wrapper.find('.bg-primary').exists()).toBe(false)
})
it('renders nothing, not even a dialog host, without actions', () => {
const { wrapper } = mountActions({}, { actions: [] })
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
expect(wrapper.html()).not.toContain('button')
})
it('asks with the action confirm text, or the default naming the action', async () => {
const { wrapper } = mountActions({})
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Lift the ban on this person?')
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Reinstate')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Run “Activate” on this record?')
})
it('posts an empty object to the action route of the record and emits done with the server message', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') })
await run(wrapper, 'activate')
const posts = requestsTo(calls, 'POST', `${RECORD}/actions/activate`)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({})
expect(wrapper.emitted('done')).toEqual([['The person was activated.']])
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
expect(toasts.value).toHaveLength(0)
})
it('emits done with the default text when the server sends no message', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('') })
await run(wrapper, 'activate')
expect(wrapper.emitted('done')).toEqual([['Action completed.']])
})
it('sends nothing and emits nothing on cancel', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('x') })
await run(wrapper, 'activate', 'cancel')
expect(calls).toHaveLength(0)
expect(wrapper.emitted()).toEqual({})
expect(dialog()).toBeNull()
})
it('keeps the dialog open and busy and every button disabled until the request settles', async () => {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: () => pending })
await button(wrapper, 'activate').trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.disabled).toBe(true)
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.emitted('busy')).toEqual([[true]])
expect(wrapper.emitted('done')).toBeUndefined()
release(done('Done.'))
await flushPromises()
expect(dialog()).toBeNull()
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true)
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
expect(wrapper.emitted('done')).toEqual([['Done.']])
})
it('409: a danger toast and the stale event, no done', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(409, 'Conflict') })
await run(wrapper, 'activate')
expect(wrapper.emitted('stale')).toHaveLength(1)
expect(wrapper.emitted('done')).toBeUndefined()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies to this record.', 'danger']])
})
it('404: the gone event and no toast', async () => {
const { wrapper } = mountActions({})
await run(wrapper, 'activate')
expect(wrapper.emitted('gone')).toHaveLength(1)
expect(wrapper.emitted('stale')).toBeUndefined()
expect(toasts.value).toHaveLength(0)
})
it('403: a danger toast with the server message or the fallback, and no event', async () => {
const withMessage = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, 'Only team leads may activate people.') })
await run(withMessage.wrapper, 'activate')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Only team leads may activate people.', 'danger']])
expect(Object.keys(withMessage.wrapper.emitted())).toEqual(['busy'])
withMessage.wrapper.unmount()
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
const bare = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, '') })
await run(bare.wrapper, 'activate')
expect(toasts.value.map((toast) => toast.text)).toEqual(['You do not have permission to run this action.'])
})
it('any other failure: a danger toast with the server message or the fallback', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(500, '') })
await run(wrapper, 'activate')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The action could not be completed.', 'danger']])
expect(wrapper.emitted('done')).toBeUndefined()
})
it('does nothing while the host disables it', async () => {
const { wrapper, calls } = mountActions({}, { disabled: true })
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()).toBeNull()
expect(calls).toHaveLength(0)
})
})