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:
98
admin/tests/browser/record-actions-wrap.test.ts
Normal file
98
admin/tests/browser/record-actions-wrap.test.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
|
||||
122
admin/tests/form/trigger.test.ts
Normal file
122
admin/tests/form/trigger.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user