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

@@ -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.')
})
})