diff --git a/admin/tests/browser/record-actions-wrap.test.ts b/admin/tests/browser/record-actions-wrap.test.ts new file mode 100644 index 00000000..77dddad4 --- /dev/null +++ b/admin/tests/browser/record-actions-wrap.test.ts @@ -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('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) + } + } + }) +}) diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index b45c0c54..1a34a3af 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -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>['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) { + const envelope = clone(rosterRecordFixture) + Object.assign(envelope.data, values) + return { body: envelope } + } + + function people(overrides: Record): Record { + 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), + ...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 { + await wrapper.find(`[role="tab"][data-tab="${key}"]`).trigger('click') + await flushPromises() + } + async function save(wrapper: Mounted): Promise { + await wrapper.find('[data-action="save"]').trigger('click') + await flushPromises() + } + async function bodies(calls: Request[]): Promise[]> { + return Promise.all(requestsTo(calls, 'PUT', PERSON).map(async (request) => (await request.clone().json()) as Record)) + } + + 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.') + }) +}) diff --git a/admin/tests/form/PermissionEditorField.test.ts b/admin/tests/form/PermissionEditorField.test.ts index 9525d338..c4577f75 100644 --- a/admin/tests/form/PermissionEditorField.test.ts +++ b/admin/tests/form/PermissionEditorField.test.ts @@ -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') + }) +}) diff --git a/admin/tests/form/PreviewView.test.ts b/admin/tests/form/PreviewView.test.ts index 4cce0efe..23f6c091 100644 --- a/admin/tests/form/PreviewView.test.ts +++ b/admin/tests/form/PreviewView.test.ts @@ -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) + }) +}) diff --git a/admin/tests/form/formState.test.ts b/admin/tests/form/formState.test.ts index af1d17a8..4adc9560 100644 --- a/admin/tests/form/formState.test.ts +++ b/admin/tests/form/formState.test.ts @@ -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 } }) + }) +}) diff --git a/admin/tests/form/trigger.test.ts b/admin/tests/form/trigger.test.ts new file mode 100644 index 00000000..728518f0 --- /dev/null +++ b/admin/tests/form/trigger.test.ts @@ -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']) + }) +}) diff --git a/admin/vitest.browser.config.ts b/admin/vitest.browser.config.ts index f794c510..3524951b 100644 --- a/admin/vitest.browser.config.ts +++ b/admin/vitest.browser.config.ts @@ -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,