test(19.6.1-03): SPA trigger, record lock and footer wrap tests

- trigger.test.ts: triggerState and applyTriggers
- FormView: disable, absent-controller and hide-tab backstops, the live
  announcement
- PermissionEditorField and formState: record-locked rows, notes and the
  payload omission, record lock over request lock, read-only screens
- PreviewView: hide removes, disable shows no note
- browser record-actions-wrap at 1024px; vitest.browser.config.ts gains
  the Tailwind plugin so the footer is laid out with the admin's CSS
This commit is contained in:
Jakub Zych
2026-10-22 12:18:34 +02:00
parent 085179b676
commit 4c61a180d0
7 changed files with 555 additions and 3 deletions

View File

@@ -0,0 +1,98 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { page } from '@vitest/browser/context'
import { defineComponent, h } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { Pencil } from '@lucide/vue'
import type { RecordAction } from '../../src/api/types'
import RecordActions from '../../src/components/form/RecordActions.vue'
import Button from '../../src/components/ui/Button.vue'
import '../../src/styles/main.css'
// UI-SPEC "UI Considerations", long text: contributed record action labels
// (D-14) of up to 40 characters next to the owner's actions in the preview
// footer at 1024px. The footer must wrap, and no button may overlap the
// primary Edit button. Needs real layout, so it runs in Vitest browser mode
// (Playwright Chromium) with the admin's own Tailwind build. Fixtures are
// neutral acme.* data.
enableAutoUnmount(afterEach)
const actions: RecordAction[] = [
{ name: 'activate', label: 'Activate' },
{ name: 'deactivate', label: 'Deactivate' },
{ name: 'restore', label: 'Restore' },
{ name: 'acme_reset_two_factor', label: 'Reset the two-factor authentication keys' },
{ name: 'acme_link_frontend', label: 'Link this account to the frontend member' },
]
/** The preview footer of PreviewView.vue, with the same classes. */
const Footer = defineComponent({
setup() {
return () =>
h(
'footer',
{
'data-footer': '',
class:
'fixed right-0 bottom-0 left-[var(--shell-offset,0px)] z-30 flex flex-wrap items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5',
},
[
h('div', { class: 'ml-auto flex flex-wrap items-center gap-2.5' }, [
h(RecordActions, { source: { vendor: 'acme', plugin: 'demo', controller: 'people' }, recordId: 1, actions }),
h(Button, { variant: 'primary', 'data-action': 'edit', icon: Pencil }, () => 'Edit person'),
]),
],
)
},
})
function overlaps(a: DOMRect, b: DOMRect): boolean {
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
}
beforeAll(async () => {
await page.viewport(1024, 768)
})
describe('preview footer with contributed record actions at 1024px', () => {
it('backstop: wraps onto more than one row and no button overlaps Edit', async () => {
expect(actions.slice(3).map((action) => action.label.length)).toEqual([40, 40])
const wrapper = mount(Footer, { attachTo: document.body })
await flushPromises()
expect(window.innerWidth).toBe(1024)
const footer = wrapper.get('[data-footer]').element as HTMLElement
expect(getComputedStyle(footer).flexWrap).toBe('wrap')
const buttons = Array.from(footer.querySelectorAll<HTMLButtonElement>('button'))
expect(buttons.map((button) => button.dataset.recordAction ?? button.dataset.action)).toEqual([
'activate',
'deactivate',
'restore',
'acme_reset_two_factor',
'acme_link_frontend',
'edit',
])
const rects = buttons.map((button) => button.getBoundingClientRect())
// Every button is laid out, inside the viewport, and none is squeezed:
// the long labels stay on one line in full.
for (const [index, rect] of rects.entries()) {
expect(rect.width, buttons[index]!.textContent ?? '').toBeGreaterThan(0)
expect(rect.left).toBeGreaterThanOrEqual(0)
expect(rect.right).toBeLessThanOrEqual(1024)
expect(buttons[index]!.scrollWidth).toBeLessThanOrEqual(Math.ceil(rect.width))
}
const rows = new Set(rects.map((rect) => Math.round(rect.top)))
expect(rows.size).toBeGreaterThan(1)
const edit = rects.at(-1)!
for (const [index, rect] of rects.slice(0, -1).entries()) {
expect(overlaps(rect, edit), `${buttons[index]!.textContent} overlaps Edit`).toBe(false)
}
// No two buttons overlap each other either.
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
expect(overlaps(rects[i]!, rects[j]!), `${i} and ${j}`).toBe(false)
}
}
})
})

View File

@@ -17,7 +17,7 @@ import {
rosterFormSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mountApp, profile, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
import { API, mountApp, profile, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
@@ -764,3 +764,170 @@ describe('returning to the list keeps its query (D-01)', () => {
expect([...queryOf(lists[0]!).keys()]).toEqual([])
})
})
describe('field triggers on the form (D-21, UI-SPEC C6 backstops)', () => {
const PEOPLE = `${API}/acme/roster/people`
const PERSON = `${PEOPLE}/1`
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
type Field = (typeof rosterFormSchemaFixture.data.fields)[number]
const superuser: Field = { name: 'is_superuser', type: 'switch', label: 'Superuser', tab: 'Permissions' }
const disableTrigger = { action: 'disable', field: 'is_superuser', condition: 'checked' }
/** The roster schema with fields inserted before permissions and a trigger on it. */
function schema(before: Field[], trigger: Field['trigger'], after: Field[] = []) {
const envelope = clone(rosterFormSchemaFixture)
const fields = envelope.data.fields.filter((field) => field.name !== 'permissions')
const permissions = { ...envelope.data.fields.find((field) => field.name === 'permissions')!, label: 'Permission overrides', trigger }
envelope.data.fields = [...fields, ...before, permissions, ...after]
return { body: envelope }
}
function record(values: Record<string, unknown>) {
const envelope = clone(rosterRecordFixture)
Object.assign(envelope.data, values)
return { body: envelope }
}
function people(overrides: Record<string, Route>): Record<string, Route> {
return {
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
// The save answers with what it stored: the sent values over the record.
[`PUT ${PERSON}`]: async (request) => record((await request.json()) as Record<string, unknown>),
...overrides,
}
}
const editor = (wrapper: Mounted) => wrapper.find('[data-field="permissions"]')
const groups = (wrapper: Mounted) => editor(wrapper).findAll('[role="radiogroup"]')
const note = (wrapper: Mounted) => editor(wrapper).find('[data-trigger-note]')
async function tab(wrapper: Mounted, key: string): Promise<void> {
await wrapper.find(`[role="tab"][data-tab="${key}"]`).trigger('click')
await flushPromises()
}
async function save(wrapper: Mounted): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
async function bodies(calls: Request[]): Promise<Record<string, unknown>[]> {
return Promise.all(requestsTo(calls, 'PUT', PERSON).map(async (request) => (await request.clone().json()) as Record<string, unknown>))
}
beforeEach(() => {
setBundle(
{
...langFixture.data,
'backend::lang.form.trigger_disabled_on': { other: 'Not editable while “:field” is on.' },
'backend::lang.form.trigger_disabled_off': { other: 'Not editable while “:field” is off.' },
},
'en',
)
})
it('backstop: while the switch is on the overrides are read-only with the note and not sent; off, they are editable and sent', async () => {
const { wrapper, calls } = await mountApp(
'/acme/roster/people/1',
people({
[`GET ${PEOPLE}/schema/form`]: schema([superuser], disableTrigger),
[`GET ${PERSON}`]: record({ is_superuser: true }),
}),
)
await tab(wrapper, 'tab:Permissions')
expect(groups(wrapper).length).toBeGreaterThan(0)
expect(groups(wrapper).every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
expect(note(wrapper).text()).toBe('Not editable while “Superuser” is on.')
expect(editor(wrapper).find('[role="group"]').attributes('aria-describedby')).toContain(note(wrapper).attributes('id'))
await save(wrapper)
let sent = await bodies(calls)
expect(sent[0]!.is_superuser).toBe(true)
expect('permissions' in sent[0]!).toBe(false)
await wrapper.find('[data-field="is_superuser"] [role="switch"]').trigger('click')
await flushPromises()
expect(note(wrapper).exists()).toBe(false)
expect(groups(wrapper).some((group) => group.attributes('aria-disabled') === 'true' && !group.element.closest('[data-locked]'))).toBe(false)
await save(wrapper)
sent = await bodies(calls)
expect(sent[1]!.is_superuser).toBe(false)
expect(sent[1]!.permissions).toEqual({ 'posts.edit': 1, 'reports.export': 1 })
})
it('announces the note when the switch turns the editor read-only, and not when it lets go', async () => {
const { wrapper } = await mountApp(
'/acme/roster/people/1',
people({
[`GET ${PEOPLE}/schema/form`]: schema([superuser], disableTrigger),
[`GET ${PERSON}`]: record({ is_superuser: false }),
}),
)
await tab(wrapper, 'tab:Permissions')
const live = () => wrapper.find('[data-form-live]').text()
const toggle = wrapper.find('[data-field="is_superuser"] [role="switch"]')
await toggle.trigger('click')
await flushPromises()
await wait(80)
expect(live()).toBe('Permission overrides: Not editable while “Superuser” is on.')
await toggle.trigger('click')
await flushPromises()
await wait(80)
expect(live()).toBe('Permission overrides: Not editable while “Superuser” is on.')
expect(note(wrapper).exists()).toBe(false)
})
it('backstop: with the controlling switch missing from the schema the editor is editable and its values are sent', async () => {
const { wrapper, calls } = await mountApp(
'/acme/roster/people/1',
people({
[`GET ${PEOPLE}/schema/form`]: schema([], disableTrigger),
[`GET ${PERSON}`]: record({ is_superuser: true }),
}),
)
await tab(wrapper, 'tab:Permissions')
expect(note(wrapper).exists()).toBe(false)
expect(editor(wrapper).find('[data-permission="posts.edit"] [role="radiogroup"]').attributes('aria-disabled')).toBeUndefined()
await save(wrapper)
const [sent] = await bodies(calls)
expect('is_superuser' in sent!).toBe(false)
expect(sent!.permissions).toEqual({ 'posts.edit': 1, 'reports.export': 1 })
})
it('backstop: hiding every field of the active tab removes the tab, activates the first one, and shows its server error in the banner', async () => {
const vip: Field = { name: 'vip', type: 'switch', label: 'VIP' }
const bio: Field = { name: 'bio', type: 'textarea', label: 'Biography', tab: 'Profile' }
let attempt = 0
const { wrapper, calls } = await mountApp(
'/acme/roster/people/1',
people({
[`GET ${PEOPLE}/schema/form`]: schema([vip], { action: 'hide', field: 'vip', condition: 'checked' }, [bio]),
[`GET ${PERSON}`]: record({ vip: false, bio: '' }),
[`PUT ${PERSON}`]: () => {
attempt += 1
return attempt === 1
? record({ vip: true, bio: '' })
: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { permissions: ['Overrides are refused.'] } } } }
},
}),
)
const tabKeys = () => wrapper.findAll('[role="tab"]').map((item) => item.attributes('data-tab'))
const selected = () => wrapper.find('[role="tab"][aria-selected="true"]').attributes('data-tab')
expect(tabKeys()).toContain('tab:Permissions')
await tab(wrapper, 'tab:Permissions')
expect(selected()).toBe('tab:Permissions')
expect(editor(wrapper).exists()).toBe(true)
// The save stores VIP on: the trigger hides the tab's only field.
await save(wrapper)
expect(tabKeys()).not.toContain('tab:Permissions')
expect(tabKeys()).toContain('tab:Profile')
expect(selected()).toBe(tabKeys()[0])
expect(editor(wrapper).exists()).toBe(false)
await save(wrapper)
const sent = await bodies(calls)
expect('permissions' in sent[0]!).toBe(true)
expect('permissions' in sent[1]!).toBe(false)
const banner = wrapper.find('[data-error-banner]')
expect(banner.exists()).toBe(true)
expect(banner.text()).toContain('Overrides are refused.')
})
})

View File

@@ -2,11 +2,13 @@
// are neutral acme.roster.* data; no application names appear in framework
// tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import type { FormField } from '../../src/api/types'
import type { FormField, PermissionRecordLock } 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 { FORM_PERMISSION_LOCKS } from '../../src/components/form/formContext'
import { editablePayload } from '../../src/components/form/formState'
import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed'
import { keydown, resetState } from '../helpers'
@@ -216,3 +218,90 @@ describe('permission editor component (UI-SPEC S5, D-16)', () => {
expect(permissionValues(field({ permissionOptions: [] }), stored)).toEqual({})
})
})
describe('permissions the record fixes (D-21, UI-SPEC C7)', () => {
const pluginNote = 'Granted to this role by a plugin. It cannot be removed here.'
const selfNote = 'You cannot change your own permissions.'
// The Lock icon; the checkbox's own Check icon is an svg too.
const lockIcon = 'svg[class*="lock"]'
function mountLocked(locks: PermissionRecordLock[], props: { field?: FormField; modelValue?: unknown } = {}) {
return mount(PermissionEditorField, {
props: { field: props.field ?? field(), modelValue: props.modelValue ?? {}, controlId: 'p' },
attachTo: document.body,
global: { provide: { [FORM_PERMISSION_LOCKS as symbol]: ref({ permissions: locks }) } },
})
}
it('backstop: a checkbox editor shows a record-locked code checked and disabled with the record note, and never sends it', async () => {
const checkboxField = field({ mode: 'checkbox' })
const locks = [{ code: 'posts.edit', value: 1, note: pluginNote }]
// Stored without the code: the lock's value is shown.
const wrapper = mountLocked(locks, { field: checkboxField, modelValue: { 'misc.beta': 1 } })
const locked = row(wrapper, 'posts.edit')
expect(locked.attributes('data-locked')).toBe('')
expect(locked.attributes('data-locked-by')).toBe('record')
expect(locked.find('[data-permission-locked]').text()).toBe(pluginNote)
expect(locked.find(lockIcon).exists()).toBe(true)
expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true')
expect(box(wrapper, 'posts.edit').attributes('aria-disabled')).toBe('true')
expect(box(wrapper, 'posts.edit').attributes('aria-describedby')).toBe(locked.find('[data-permission-locked]').attributes('id'))
await box(wrapper, 'posts.edit').trigger('click')
expect(emitted(wrapper)).toEqual([])
// Another row still edits, and what it emits leaves the locked code out.
await box(wrapper, 'posts.publish').trigger('click')
expect(emitted(wrapper).at(-1)).toEqual({ 'misc.beta': 1, 'posts.publish': 1 })
expect(row(wrapper, 'posts.publish').attributes('data-locked-by')).toBeUndefined()
// The save body omits it, by permissionValues and by editablePayload.
const value = { 'posts.edit': 1, 'misc.beta': 1 }
expect(permissionValues(checkboxField, value, new Set(['posts.edit']))).toEqual({ 'misc.beta': 1 })
expect(editablePayload([checkboxField], { permissions: value }, 'update', { permissions: locks })).toEqual({ permissions: { 'misc.beta': 1 } })
})
it('a radio editor shows the lock value, deny included', () => {
const wrapper = mountLocked(
[
{ code: 'posts.edit', value: -1, note: selfNote },
{ code: 'posts.publish', value: 0, note: selfNote },
],
{ modelValue: { 'posts.edit': 1, 'posts.publish': 1 } },
)
expect(checked(wrapper, 'posts.edit').text()).toBe('Deny')
expect(checked(wrapper, 'posts.publish').text()).toBe('Inherit')
expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').attributes('aria-disabled')).toBe('true')
expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').text()).toBe(selfNote)
})
it('a record lock wins over a request lock: its value and note are shown', () => {
// reports.export is request-locked in the fixture.
const wrapper = mountLocked([{ code: 'reports.export', value: -1, note: selfNote }], { modelValue: { 'reports.export': 1 } })
const locked = row(wrapper, 'reports.export')
expect(locked.attributes('data-locked-by')).toBe('record')
expect(locked.find('[data-permission-locked]').text()).toBe(selfNote)
expect(checked(wrapper, 'reports.export').text()).toBe('Deny')
// The request-locked row without a record lock keeps the request text.
const plain = mountLocked([], { modelValue: { 'reports.export': 1 } })
expect(row(plain, 'reports.export').attributes('data-locked-by')).toBeUndefined()
expect(row(plain, 'reports.export').find('[data-permission-locked]').text()).toBe('You cannot change this permission.')
})
it('a read-only screen shows the lock value without an icon or a note', () => {
const wrapper = mountLocked([{ code: 'posts.edit', value: 1, note: pluginNote }], { field: field({ mode: 'checkbox', readOnly: true }), modelValue: {} })
const locked = row(wrapper, 'posts.edit')
expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true')
expect(locked.attributes('data-locked')).toBeUndefined()
expect(locked.attributes('data-locked-by')).toBeUndefined()
expect(locked.find('[data-permission-locked]').exists()).toBe(false)
expect(locked.find(lockIcon).exists()).toBe(false)
})
it('locks of another field are ignored', () => {
const wrapper = mount(PermissionEditorField, {
props: { field: field(), modelValue: { 'posts.edit': 1 }, controlId: 'p' },
attachTo: document.body,
global: { provide: { [FORM_PERMISSION_LOCKS as symbol]: ref({ other: [{ code: 'posts.edit', value: -1, note: selfNote }] }) } },
})
expect(row(wrapper, 'posts.edit').attributes('data-locked')).toBeUndefined()
expect(checked(wrapper, 'posts.edit').text()).toBe('Allow')
})
})

View File

@@ -343,3 +343,43 @@ describe('preview edit rule (UI-SPEC F6)', () => {
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
})
})
describe('field triggers on the preview (D-21, UI-SPEC C6)', () => {
type Field = (typeof rosterFormSchemaFixture.data.fields)[number]
const vip: Field = { name: 'vip', type: 'switch', label: 'VIP' }
function triggeredSchema() {
return schemaWith((schema) => {
for (const field of schema.fields) {
if (field.name === 'slug') {
field.trigger = { action: 'hide', field: 'vip', condition: 'checked' }
}
if (field.name === 'email') {
field.trigger = { action: 'disable', field: 'vip', condition: 'checked' }
}
}
schema.fields.unshift(vip)
})
}
it('a hide trigger removes the field while it holds; a disable trigger shows the value without a note', async () => {
const { wrapper } = await mountApp(
PREVIEW,
routes({ [`GET ${LIST}/schema/form`]: triggeredSchema(), [`GET ${RECORD}`]: recordWith((record) => Object.assign(record.data, { vip: true })) }),
)
expect(fields(wrapper)).not.toContain('slug')
expect(fields(wrapper)).toContain('email')
expect(wrapper.find('[data-preview-field="email"]').text()).toContain('ada@example.test')
expect(wrapper.find('[data-trigger-note]').exists()).toBe(false)
})
it('shows the field again while the trigger does not hold', async () => {
const { wrapper } = await mountApp(
PREVIEW,
routes({ [`GET ${LIST}/schema/form`]: triggeredSchema(), [`GET ${RECORD}`]: recordWith((record) => Object.assign(record.data, { vip: false })) }),
)
expect(fields(wrapper)).toContain('slug')
expect(fields(wrapper)).toContain('email')
expect(wrapper.find('[data-trigger-note]').exists()).toBe(false)
})
})

View File

@@ -367,3 +367,37 @@ describe('plugin field values (Phase 16.2)', () => {
})
})
})
describe('record permission locks in the save body (D-21, UI-SPEC C7)', () => {
const radio = (): FormField => ({ ...rosterFormSchemaFixture.data.fields.find((field) => field.name === 'permissions')! })
const checkbox = (): FormField => ({ ...radio(), mode: 'checkbox' })
const stored = { 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1, 'misc.beta': 1 }
it('backstop: a record-locked code is left out of a checkbox editor payload', () => {
const locks = { permissions: [{ code: 'posts.edit', value: 1, note: 'Granted to this role by a plugin. It cannot be removed here.' }] }
expect(editablePayload([checkbox()], { permissions: stored }, 'update', locks)).toEqual({ permissions: { 'reports.export': 1, 'misc.beta': 1 } })
// Without locks the same value is sent.
expect(editablePayload([checkbox()], { permissions: stored }, 'update')).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 } })
})
it('a record-locked code is left out of a radio editor payload, whatever its value', () => {
const locks = {
permissions: [
{ code: 'posts.publish', value: -1, note: 'You cannot change your own permissions.' },
{ code: 'misc.beta', value: 0, note: 'You cannot change your own permissions.' },
],
}
expect(editablePayload([radio()], { permissions: stored }, 'update', locks)).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': 1 } })
})
it('every code locked still sends the field, as an empty object', () => {
const codes = ['posts.edit', 'posts.publish', 'reports.export', 'misc.beta']
const locks = { permissions: codes.map((code) => ({ code, value: 0, note: 'Locked' })) }
expect(editablePayload([radio()], { permissions: stored }, 'update', locks)).toEqual({ permissions: {} })
})
it('locks of another field change nothing', () => {
const locks = { other_permissions: [{ code: 'posts.edit', value: 1, note: 'Locked' }] }
expect(editablePayload([radio()], { permissions: stored }, 'update', locks)).toEqual({ permissions: { 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1, 'misc.beta': 1 } })
})
})

View File

@@ -0,0 +1,122 @@
// Field triggers (fields.yaml `trigger`, D-21, UI-SPEC C6): the pure
// helpers FormView and PreviewView call with the whole form's values.
// Fixtures are neutral acme.* data; no application names appear in
// framework tests.
import { beforeEach, describe, expect, it } from 'vitest'
import type { AdminRecord, FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import { applyTriggers, triggerState } from '../../src/components/form/trigger'
const strings = {
'backend::lang.form.trigger_disabled_on': { other: 'Not editable while “:field” is on.' },
'backend::lang.form.trigger_disabled_off': { other: 'Not editable while “:field” is off.' },
}
const control: FormField = { name: 'is_admin', type: 'switch', label: 'Administrator' }
function triggered(action: 'disable' | 'hide', condition: 'checked' | 'unchecked', name = 'notes'): FormField {
return { name, type: 'textarea', label: 'Notes', trigger: { action, field: 'is_admin', condition } }
}
const labelOf = (name: string) => (name === 'is_admin' ? 'Administrator' : name)
beforeEach(() => {
setBundle(strings, 'en')
})
describe('triggerState', () => {
const present = new Set(['is_admin', 'notes'])
const on: unknown[] = [true, 1, '1', 'true', 'TRUE', 'yes']
const off: unknown[] = [false, 0, '0', '', 'false', null, undefined]
it('a checked trigger is active while the controlling value is on', () => {
for (const value of on) {
expect(triggerState(triggered('disable', 'checked'), { is_admin: value }, present), String(value)).toBe('active')
}
for (const value of off) {
expect(triggerState(triggered('disable', 'checked'), { is_admin: value }, present), String(value)).toBe('inactive')
}
})
it('an unchecked trigger is active while the controlling value is off', () => {
for (const value of on) {
expect(triggerState(triggered('hide', 'unchecked'), { is_admin: value }, present), String(value)).toBe('inactive')
}
for (const value of off) {
expect(triggerState(triggered('hide', 'unchecked'), { is_admin: value }, present), String(value)).toBe('active')
}
})
it('a missing value counts as off', () => {
expect(triggerState(triggered('disable', 'checked'), {}, present)).toBe('inactive')
expect(triggerState(triggered('disable', 'unchecked'), {}, present)).toBe('active')
})
it('a controlling field absent from the schema counts as off, whatever the values hold', () => {
const absent = new Set(['notes'])
expect(triggerState(triggered('disable', 'checked'), { is_admin: true }, absent)).toBe('inactive')
expect(triggerState(triggered('hide', 'unchecked'), { is_admin: true }, absent)).toBe('active')
})
it('a field without a trigger is always inactive', () => {
expect(triggerState({ name: 'title', type: 'text' }, { is_admin: true }, present)).toBe('inactive')
})
})
describe('applyTriggers', () => {
it('disable makes an active field a read-only copy with the note naming the controlling field', () => {
const notes = triggered('disable', 'checked')
const fields = [control, notes]
const values: AdminRecord = { is_admin: true }
const result = applyTriggers(fields, values, labelOf)
expect(result.fields.map((field) => field.name)).toEqual(['is_admin', 'notes'])
expect(result.fields[1]!.readOnly).toBe(true)
expect(result.fields[1]).not.toBe(notes)
expect(result.notes).toEqual({ notes: 'Not editable while “Administrator” is on.' })
// The inputs are not changed.
expect(notes.readOnly).toBeUndefined()
expect(fields).toHaveLength(2)
expect(values).toEqual({ is_admin: true })
})
it('an unchecked disable trigger uses the off note', () => {
const result = applyTriggers([control, triggered('disable', 'unchecked')], { is_admin: false }, labelOf)
expect(result.notes).toEqual({ notes: 'Not editable while “Administrator” is off.' })
})
it('an inactive trigger leaves the field as it is, without a note', () => {
const notes = triggered('disable', 'checked')
const result = applyTriggers([control, notes], { is_admin: false }, labelOf)
expect(result.fields[1]).toBe(notes)
expect(result.notes).toEqual({})
})
it('hide removes an active field and keeps the order of the rest', () => {
const title: FormField = { name: 'title', type: 'text' }
const result = applyTriggers([title, control, triggered('hide', 'checked'), { name: 'body', type: 'text' }], { is_admin: '1' }, labelOf)
expect(result.fields.map((field) => field.name)).toEqual(['title', 'is_admin', 'body'])
expect(result.notes).toEqual({})
})
it('a controlling field missing from the fields leaves a checked trigger inactive', () => {
const notes = triggered('disable', 'checked')
const result = applyTriggers([notes], { is_admin: true }, labelOf)
expect(result.fields).toEqual([notes])
expect(result.fields[0]!.readOnly).toBeUndefined()
expect(result.notes).toEqual({})
})
it('several triggers on one switch apply together', () => {
const result = applyTriggers(
[control, triggered('disable', 'checked', 'a'), triggered('hide', 'checked', 'b'), triggered('hide', 'unchecked', 'c')],
{ is_admin: 1 },
labelOf,
)
expect(result.fields.map((field) => [field.name, !!field.readOnly])).toEqual([
['is_admin', false],
['a', true],
['c', false],
])
expect(Object.keys(result.notes)).toEqual(['a'])
})
})

View File

@@ -1,12 +1,14 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// Vitest browser mode in Playwright Chromium (npm run test:browser), for tests
// that need a real engine: happy-dom reproduces neither DOM clobbering nor
// DOMPurify's walk. tests/setup.ts is a happy-dom shim and is not loaded here.
// Decision note: .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
// Tailwind compiles src/styles/main.css for layout tests (record-actions-wrap).
export default defineConfig({
plugins: [vue()],
plugins: [vue(), tailwindcss()],
test: {
include: ['tests/browser/**/*.test.ts'],
restoreMocks: true,