diff --git a/admin/tests/form/FormErrorBanner.test.ts b/admin/tests/form/FormErrorBanner.test.ts new file mode 100644 index 0000000..60f0a59 --- /dev/null +++ b/admin/tests/form/FormErrorBanner.test.ts @@ -0,0 +1,89 @@ +// The form's error banner: the 422 summary and the forbidden save banner +// (UI-SPEC S6, D-27). No application names appear in framework tests. +import { beforeEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' +import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue' +import { langFixture } from '../fixtures/typed' +import { resetState } from '../helpers' + +const strings = { + 'backend::lang.form.error_title': { other: 'The record could not be saved.' }, + 'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' }, +} +const fieldNames = ['name', 'email'] + +function banner(props: { errors?: Record; forbidden?: string | null }) { + return mount(FormErrorBanner, { props: { errors: props.errors ?? {}, fieldNames, forbidden: props.forbidden } }) +} + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +describe('form error banner', () => { + it('renders nothing without errors and without a refusal', () => { + const wrapper = banner({}) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.text()).toBe('') + }) + + it('counts the 422 fields and lists messages of keys that are not fields', () => { + const wrapper = banner({ errors: { name: ['Too short.'], record: ['The record is locked.', 'Try later.'] } }) + const alert = wrapper.find('[data-error-banner]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text()).toContain('The record could not be saved.') + expect(alert.text()).toContain('Correct 2 fields.') + expect(alert.text()).toContain('The record is locked.') + expect(alert.text()).toContain('Try later.') + // A field's own message is shown on the field, not here. + expect(alert.text()).not.toContain('Too short.') + expect(banner({ errors: { name: ['x'] } }).text()).toContain('Correct 1 field.') + }) +}) + +describe('forbidden save banner (UI-SPEC S6, D-27)', () => { + it('shows the refusal as a persistent alert in place of the 422 banner', () => { + const wrapper = banner({ forbidden: 'You may not change this group.', errors: { name: ['Reserved.'] } }) + const alert = wrapper.find('[data-forbidden-banner]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.find('p').text()).toBe('You may not change this group.') + expect(alert.find('svg').attributes('aria-hidden')).toBe('true') + expect(alert.classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger'])) + expect(wrapper.find('[data-error-banner]').exists()).toBe(false) + // Field messages stay on their fields; no count line here. + expect(alert.text()).not.toContain('Reserved.') + expect(alert.text()).not.toContain('Correct') + // No close button: it stays until the next save attempt. + expect(alert.find('button').exists()).toBe(false) + }) + + it('lists details of keys that are not form fields inside the banner', () => { + const wrapper = banner({ forbidden: 'Refused.', errors: { groups: ['The admin group is locked.'], email: ['On the field.'] } }) + const lines = wrapper.findAll('[data-forbidden-banner] p').map((line) => line.text()) + expect(lines).toEqual(['Refused.', 'The admin group is locked.']) + }) + + it('wraps a long message and renders markup as text', () => { + const long = 'A very long refusal with a_long_unbroken_identifier_that_must_wrap '.repeat(6).trim() + const wrapper = banner({ forbidden: `${long} x` }) + const text = wrapper.find('[data-forbidden-banner] p') + expect(text.classes()).toContain('[overflow-wrap:anywhere]') + expect(text.text()).toContain('x') + expect(wrapper.find('b').exists()).toBe(false) + expect(wrapper.html()).not.toContain('truncate') + }) + + it('falls back to the 422 banner when the refusal is cleared', async () => { + const wrapper = banner({ forbidden: 'Refused.', errors: { name: ['x'] } }) + await wrapper.setProps({ forbidden: null }) + expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false) + expect(wrapper.find('[data-error-banner]').exists()).toBe(true) + await wrapper.setProps({ errors: {} }) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + // An empty string is not a refusal. + await wrapper.setProps({ forbidden: '' }) + expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false) + }) +}) diff --git a/admin/tests/form/PasswordField.test.ts b/admin/tests/form/PasswordField.test.ts new file mode 100644 index 0000000..8099177 --- /dev/null +++ b/admin/tests/form/PasswordField.test.ts @@ -0,0 +1,116 @@ +// Password control (UI-SPEC S7, D-19), the component on its own. No +// application names appear in framework tests. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils' +import { nextTick, ref } from 'vue' +import type { FormField } from '../../src/api/types' +import { setBundle } from '../../src/app/i18n' +import PasswordField from '../../src/components/form/fields/PasswordField.vue' +import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext' +import { langFixture } from '../fixtures/typed' +import { resetState } from '../helpers' + +const strings = { + 'backend::lang.form.show_password': { other: 'Show password' }, + 'backend::lang.form.hide_password': { other: 'Hide password' }, +} + +function mountField(props: Record = {}, revision = ref(0)) { + const field = { name: 'password', type: 'password', label: 'Password', ...(props.field as object) } as FormField + const wrapper = mount(PasswordField, { + props: { modelValue: '', controlId: 'f-password', ...props, field }, + global: { provide: { [FORM_SESSION as symbol]: { revision } as unknown as FormSession } }, + attachTo: document.body, + }) + return { wrapper, revision } +} + +const input = (wrapper: VueWrapper) => wrapper.find('input') +const toggle = (wrapper: VueWrapper) => wrapper.find('[data-password-toggle]') + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('password field component (UI-SPEC S7, D-19)', () => { + it('is an empty, masked input that asks the browser for a new password', () => { + const { wrapper } = mountField() + expect(input(wrapper).attributes('type')).toBe('password') + expect(input(wrapper).element.value).toBe('') + expect(input(wrapper).attributes('id')).toBe('f-password') + expect(input(wrapper).attributes('name')).toBe('password') + expect(input(wrapper).attributes('autocomplete')).toBe('new-password') + expect(input(wrapper).attributes('spellcheck')).toBe('false') + }) + + it('never shows a value that is not a string', () => { + for (const modelValue of [null, undefined, 12345, { hash: '$2a$10$abc' }, ['x']]) { + const { wrapper } = mountField({ modelValue }) + expect(input(wrapper).element.value).toBe('') + wrapper.unmount() + } + }) + + it('emits what is typed and does not keep a copy of its own', async () => { + const { wrapper } = mountField() + await input(wrapper).setValue('s3cret-pass') + expect(wrapper.emitted('update:modelValue')).toEqual([['s3cret-pass']]) + // The control is driven by modelValue: the host clears it after a save. + await wrapper.setProps({ modelValue: 's3cret-pass' }) + await wrapper.setProps({ modelValue: '' }) + expect(input(wrapper).element.value).toBe('') + }) + + it('toggles between hidden and visible text and says so with aria-pressed', async () => { + const { wrapper } = mountField({ modelValue: 'visible-text' }) + expect(toggle(wrapper).attributes('type')).toBe('button') + expect(toggle(wrapper).attributes('aria-pressed')).toBe('false') + expect(toggle(wrapper).attributes('aria-label')).toBe('Show password') + await toggle(wrapper).trigger('click') + expect(input(wrapper).attributes('type')).toBe('text') + expect(toggle(wrapper).attributes('aria-pressed')).toBe('true') + expect(toggle(wrapper).attributes('aria-label')).toBe('Hide password') + await toggle(wrapper).trigger('click') + expect(input(wrapper).attributes('type')).toBe('password') + expect(toggle(wrapper).attributes('aria-pressed')).toBe('false') + // The toggle changes no value. + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('returns to hidden when the form session moves on (after a save)', async () => { + const { wrapper, revision } = mountField({ modelValue: 'visible-text' }) + await toggle(wrapper).trigger('click') + expect(input(wrapper).attributes('type')).toBe('text') + revision.value += 1 + await nextTick() + expect(input(wrapper).attributes('type')).toBe('password') + expect(toggle(wrapper).attributes('aria-pressed')).toBe('false') + }) + + it('works without a form session', async () => { + const wrapper = mount(PasswordField, { + props: { field: { name: 'password', type: 'password' } as FormField, modelValue: '', controlId: 'x' }, + }) + await wrapper.find('[data-password-toggle]').trigger('click') + expect(wrapper.find('input').attributes('type')).toBe('text') + }) + + it('marks required and invalid, and points at its comment and error lines', () => { + const { wrapper } = mountField({ field: { required: true }, invalid: true, describedBy: 'f-password-error' }) + expect(input(wrapper).attributes('required')).toBeDefined() + expect(input(wrapper).attributes('aria-required')).toBe('true') + expect(input(wrapper).attributes('aria-invalid')).toBe('true') + expect(input(wrapper).attributes('aria-describedby')).toBe('f-password-error') + expect(input(wrapper).classes()).toContain('border-danger') + const plain = mountField() + expect(input(plain.wrapper).attributes('aria-invalid')).toBeUndefined() + expect(input(plain.wrapper).attributes('required')).toBeUndefined() + }) +}) diff --git a/admin/tests/form/PermissionEditorField.test.ts b/admin/tests/form/PermissionEditorField.test.ts new file mode 100644 index 0000000..9525d33 --- /dev/null +++ b/admin/tests/form/PermissionEditorField.test.ts @@ -0,0 +1,218 @@ +// Permission editor (UI-SPEC S5, D-16), the component on its own. Fixtures +// are neutral acme.roster.* data; no application names appear in framework +// tests. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import type { FormField } from '../../src/api/types' +import { setBundle } from '../../src/app/i18n' +import { permissionValues } from '../../src/components/form/control' +import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue' +import { editablePayload } from '../../src/components/form/formState' +import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed' +import { keydown, resetState } from '../helpers' + +const strings = { + 'backend::lang.permissioneditor.allow': { other: 'Allow' }, + 'backend::lang.permissioneditor.inherit': { other: 'Inherit' }, + 'backend::lang.permissioneditor.deny': { other: 'Deny' }, + 'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' }, + 'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' }, + 'backend::lang.permissioneditor.other': { other: 'Other' }, +} + +/** The roster permissions field: Content (2), Reports (1, locked), untabbed (1). */ +function field(change: Partial = {}): FormField { + return { ...clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!), ...change } +} + +function mountEditor(props: { field?: FormField; modelValue?: unknown; invalid?: boolean; describedBy?: string } = {}) { + return mount(PermissionEditorField, { + props: { field: props.field ?? field(), modelValue: props.modelValue ?? {}, controlId: 'p', invalid: props.invalid, describedBy: props.describedBy }, + attachTo: document.body, + }) +} + +const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`) +const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`) +const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]') +const box = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[role="checkbox"]') +const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0]) + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('permission editor component (UI-SPEC S5, D-16)', () => { + it('groups the options into sections by tab, in the order sent, untabbed ones last as Other', () => { + const options = [ + { code: 'z.first', label: 'Z first', tab: 'Zeta' }, + { code: 'loose.one', label: 'Loose one' }, + { code: 'a.one', label: 'A one', tab: 'Alpha' }, + { code: 'z.second', label: 'Z second', tab: 'Zeta' }, + { code: 'loose.two', label: '' }, + ] + const wrapper = mountEditor({ field: field({ permissionOptions: options }) }) + const sections = wrapper.findAll('[data-permission-section]') + expect(sections.map((section) => section.find('h3 span').text())).toEqual(['Zeta', 'Alpha', 'Other']) + expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([ + ['z.first', 'z.second'], + ['a.one'], + ['loose.one', 'loose.two'], + ]) + // Every section is a group named by its heading; a label falls back to the code. + expect(sections.every((section) => section.attributes('role') === 'group' && section.attributes('aria-labelledby') === section.find('h3').attributes('id'))).toBe(true) + expect(row(wrapper, 'loose.two').text()).toContain('loose.two') + }) + + it('has no Other section when every option has a tab', () => { + const wrapper = mountEditor({ field: field({ permissionOptions: [{ code: 'a.one', label: 'A', tab: 'Alpha' }] }) }) + expect(wrapper.findAll('[data-permission-section]').map((section) => section.find('h3 span').text())).toEqual(['Alpha']) + }) + + it('shows the empty state without options, keeping the control id for the label', () => { + for (const permissionOptions of [[], undefined]) { + const wrapper = mountEditor({ field: field({ permissionOptions }) }) + const empty = wrapper.find('[data-permission-empty]') + expect(empty.text()).toBe('No permissions are defined yet.') + expect(empty.attributes('id')).toBe('p') + expect(wrapper.find('[data-permission-editor]').exists()).toBe(false) + wrapper.unmount() + } + }) + + it('names the group by the field label, or by the field name without one', () => { + const labelled = mountEditor() + expect(labelled.find('[data-permission-editor]').attributes('aria-labelledby')).toBe('p-label') + expect(labelled.find('[data-permission-editor]').attributes('aria-label')).toBeUndefined() + const bare = mountEditor({ field: field({ label: '' }) }) + expect(bare.find('[data-permission-editor]').attributes('aria-label')).toBe('permissions') + expect(bare.find('[data-permission-editor]').attributes('aria-labelledby')).toBeUndefined() + }) + + it('shows the invalid border and points at the error line', async () => { + const wrapper = mountEditor({ invalid: true, describedBy: 'p-error' }) + const editor = wrapper.find('[data-permission-editor]') + expect(editor.classes()).toContain('border-danger') + expect(editor.classes()).not.toContain('border-border') + expect(editor.attributes('aria-describedby')).toBe('p-error') + await wrapper.setProps({ invalid: false }) + expect(wrapper.find('[data-permission-editor]').classes()).toContain('border-border') + }) + + it('shows stored values: allow, deny, and inherit for a code without one', () => { + const wrapper = mountEditor({ modelValue: { 'posts.edit': 1, 'posts.publish': -1 } }) + expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('radio') + expect(checked(wrapper, 'posts.edit').text()).toBe('Allow') + expect(checked(wrapper, 'posts.publish').text()).toBe('Deny') + expect(checked(wrapper, 'misc.beta').text()).toBe('Inherit') + // Values the mode does not know read as inherit. + for (const modelValue of [{ 'posts.edit': 2 }, { 'posts.edit': 'yes' }, { 'posts.edit': null }, null, 'x', [1]]) { + const odd = mountEditor({ modelValue }) + expect(checked(odd, 'posts.edit').text()).toBe('Inherit') + odd.unmount() + } + }) + + it('shows a locked row with its stored value, a lock and its text, and a disabled control', async () => { + const wrapper = mountEditor({ modelValue: { 'reports.export': 1 } }) + const locked = row(wrapper, 'reports.export') + expect(locked.attributes('data-locked')).toBe('') + expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.') + expect(locked.find('svg').attributes('aria-hidden')).toBe('true') + expect(checked(wrapper, 'reports.export').text()).toBe('Allow') + const group = locked.find('[role="radiogroup"]') + expect(group.attributes('aria-disabled')).toBe('true') + expect(group.attributes('aria-describedby')).toBe(locked.find('[data-permission-locked]').attributes('id')) + // An unlocked row has neither. + expect(row(wrapper, 'posts.edit').attributes('data-locked')).toBeUndefined() + expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false) + }) + + it('is read-only as a whole when the field is: every control disabled, no lock shown', async () => { + for (const change of [{ readOnly: true }, { attributes: { readonly: true } }] as Partial[]) { + const wrapper = mountEditor({ field: field(change), modelValue: { 'posts.edit': 1 } }) + expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true) + expect(wrapper.find('[data-locked]').exists()).toBe(false) + expect(wrapper.find('[data-permission-locked]').exists()).toBe(false) + await segment(wrapper, 'posts.edit', '-1').trigger('click') + expect(emitted(wrapper)).toEqual([]) + wrapper.unmount() + } + const boxes = mountEditor({ field: field({ mode: 'checkbox', readOnly: true }), modelValue: {} }) + expect(boxes.findAll('[role="checkbox"]').every((item) => item.attributes('aria-disabled') === 'true')).toBe(true) + await box(boxes, 'posts.edit').trigger('click') + expect(emitted(boxes)).toEqual([]) + }) + + it('checkbox mode: one labelled checkbox per row and an Allow column heading', () => { + const wrapper = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'posts.edit': 1, 'posts.publish': -1 } }) + expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('checkbox') + expect(wrapper.find('[role="radiogroup"]').exists()).toBe(false) + expect(wrapper.findAll('[data-permission-section] h3 [aria-hidden="true"]').map((item) => item.text())).toEqual(['Allow', 'Allow', 'Allow']) + expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true') + // A stored deny is not a checkbox value: it shows unchecked. + expect(box(wrapper, 'posts.publish').attributes('aria-checked')).toBe('false') + const label = row(wrapper, 'posts.edit').find('label') + expect(label.attributes('for')).toBe(box(wrapper, 'posts.edit').attributes('id')) + expect(box(wrapper, 'reports.export').attributes('aria-disabled')).toBe('true') + }) + + it('backstop: radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent', async () => { + // Radio: a stored code that is not offered (legacy.code) and an + // out-of-set value are dropped from everything the control emits. + const stored = { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': 7, 'reports.export': -1 } + const radio = mountEditor({ modelValue: stored }) + await segment(radio, 'posts.publish', '-1').trigger('click') + expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1 }) + await radio.setProps({ modelValue: emitted(radio).at(-1) }) + await segment(radio, 'misc.beta', '1').trigger('click') + expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 }) + await radio.setProps({ modelValue: emitted(radio).at(-1) }) + // Inherit is the absence of the code, never a 0. + await segment(radio, 'posts.edit', '0').trigger('click') + expect(emitted(radio).at(-1)).toEqual({ 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 }) + expect(Object.values(emitted(radio).at(-1) as Record).every((value) => value === 1 || value === -1)).toBe(true) + + // A locked row cannot change: by click, and by the keyboard. + const before = emitted(radio).length + for (const value of ['1', '0', '-1']) { + await segment(radio, 'reports.export', value).trigger('click') + } + keydown(segment(radio, 'reports.export', '-1').element, 'ArrowLeft') + keydown(segment(radio, 'reports.export', '-1').element, ' ') + await flushPromises() + expect(emitted(radio)).toHaveLength(before) + expect(segment(radio, 'reports.export', '1').attributes('disabled')).toBeDefined() + + // Checkbox: checked is 1, unchecked is absent; a stored deny is dropped. + const checkbox = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 } }) + await box(checkbox, 'misc.beta').trigger('click') + expect(emitted(checkbox).at(-1)).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 }) + await checkbox.setProps({ modelValue: emitted(checkbox).at(-1) }) + await box(checkbox, 'posts.edit').trigger('click') + expect(emitted(checkbox).at(-1)).toEqual({ 'reports.export': 1, 'misc.beta': 1 }) + const settled = emitted(checkbox).length + await box(checkbox, 'reports.export').trigger('click') + expect(emitted(checkbox)).toHaveLength(settled) + + // The save body is reduced the same way, whatever the form state holds. + const radioField = field() + expect(permissionValues(radioField, { ...stored, 'misc.beta': 0, extra: -1 })).toEqual({ 'posts.edit': 1, 'reports.export': -1 }) + expect(editablePayload([radioField], { permissions: stored }, 'update')).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': -1 } }) + const checkboxField = field({ mode: 'checkbox' }) + expect(editablePayload([checkboxField], { permissions: { 'posts.edit': 1, 'posts.publish': -1, 'legacy.code': 1 } }, 'update')).toEqual({ + permissions: { 'posts.edit': 1 }, + }) + // Nothing stored still sends the field, as an empty object. + expect(editablePayload([radioField], {}, 'update')).toEqual({ permissions: {} }) + // A field with no options can send no code at all. + expect(permissionValues(field({ permissionOptions: [] }), stored)).toEqual({}) + }) +}) diff --git a/admin/tests/form/PreviewField.test.ts b/admin/tests/form/PreviewField.test.ts new file mode 100644 index 0000000..e0e451f --- /dev/null +++ b/admin/tests/form/PreviewField.test.ts @@ -0,0 +1,154 @@ +// One field of the preview screen (UI-SPEC S3, D-11): every row of the +// PreviewField table. Fixtures are neutral; no application names appear in +// framework tests. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import type { FormField } from '../../src/api/types' +import { setBundle } from '../../src/app/i18n' +import PreviewField from '../../src/components/form/PreviewField.vue' +import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed' +import { resetState } from '../helpers' + +const strings = { + 'backend::lang.list.empty_value': { other: '—' }, + 'backend::lang.list.column_switch_true': { other: 'Yes' }, + 'backend::lang.list.column_switch_false': { other: 'No' }, + 'backend::lang.permissioneditor.allow': { other: 'Allow' }, + 'backend::lang.permissioneditor.inherit': { other: 'Inherit' }, + 'backend::lang.permissioneditor.deny': { other: 'Deny' }, + 'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' }, + 'backend::lang.permissioneditor.other': { other: 'Other' }, +} + +function field(change: Partial): FormField { + return { name: 'note', type: 'text', label: 'Note', ...change } as FormField +} + +function preview(props: { field: FormField; value: unknown; labels?: { value: number; label: string }[] }) { + return mount(PreviewField, { props, attachTo: document.body }) +} + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('preview field (UI-SPEC S3, D-11)', () => { + it('is a dt/dd pair: the label names the value and no input is rendered', () => { + const wrapper = preview({ field: field({}), value: 'Plain text' }) + expect(wrapper.attributes('data-preview-field')).toBe('note') + expect(wrapper.find('dt').text()).toBe('Note') + expect(wrapper.find('dt').attributes('id')).toBe('preview-note-label') + expect(wrapper.find('dd [data-preview-value]').text()).toBe('Plain text') + expect(wrapper.find('input, textarea, select, button').exists()).toBe(false) + // Values keep full contrast: nothing is styled as disabled. + expect(wrapper.find('[data-preview-value] span').classes()).not.toContain('text-muted') + }) + + it('falls back to the field name without a label', () => { + expect(preview({ field: field({ label: '' }), value: 'x' }).find('dt').text()).toBe('note') + }) + + it.each([[null], [undefined], ['']])('shows the muted dash for an empty value (%j)', (value) => { + const wrapper = preview({ field: field({}), value }) + const empty = wrapper.find('[data-preview-value] [data-empty]') + expect(empty.text()).toBe('—') + expect(empty.classes()).toContain('text-muted') + }) + + it('renders 0 and false-like text as values, not as empty', () => { + expect(preview({ field: field({ type: 'number' }), value: 0 }).find('[data-preview-value]').text()).toBe('0') + expect(preview({ field: field({}), value: 'false' }).find('[data-preview-value]').text()).toBe('false') + }) + + it('renders markup in a value as text', () => { + const wrapper = preview({ field: field({}), value: '' }) + expect(wrapper.find('[data-preview-value]').text()).toBe('') + expect(wrapper.find('img').exists()).toBe(false) + }) + + it('never prints an object or an array as text', () => { + for (const value of [{ a: 1 }, [1, 2]]) { + const wrapper = preview({ field: field({}), value }) + expect(wrapper.find('[data-empty]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('[object') + } + }) + + it('keeps the line breaks of a textarea and lets long text wrap', () => { + const wrapper = preview({ field: field({ type: 'textarea' }), value: 'one\ntwo' }) + const box = wrapper.find('[data-preview-value]') + expect(box.classes()).toEqual(expect.arrayContaining(['whitespace-pre-wrap', '[overflow-wrap:anywhere]'])) + expect(box.element.textContent).toContain('one\ntwo') + }) + + it('shows the label of a dropdown option, or the stored value when no option matches', () => { + const dropdown = field({ type: 'dropdown', options: [{ value: 'a', label: 'Alpha' }, { value: '2', label: 'Two' }] } as Partial) + expect(preview({ field: dropdown, value: 'a' }).find('[data-preview-value]').text()).toBe('Alpha') + expect(preview({ field: dropdown, value: 2 }).find('[data-preview-value]').text()).toBe('Two') + expect(preview({ field: dropdown, value: 'gone' }).find('[data-preview-value]').text()).toBe('gone') + }) + + it.each([ + ['switch', true, 'true', 'Yes'], + ['switch', 0, 'false', 'No'], + ['checkbox', '1', 'true', 'Yes'], + ['checkbox', null, 'false', 'No'], + ])('renders a %s holding %j as a text pill', (type, value, state, text) => { + const wrapper = preview({ field: field({ type }), value }) + const pill = wrapper.find('[data-switch]') + expect(pill.attributes('data-switch')).toBe(state) + expect(pill.text()).toBe(text) + expect(wrapper.find('[role="switch"], [role="checkbox"], input').exists()).toBe(false) + }) + + it('shows the label of a single relation from meta.labels, else the empty option or the dash', () => { + const relation = field({ type: 'relation' }) + expect(preview({ field: relation, value: 2, labels: [{ value: 2, label: 'Home' }] }).find('[data-preview-value]').text()).toBe('Home') + // The id itself is never shown. + expect(preview({ field: relation, value: 2 }).find('[data-preview-value]').text()).toBe('—') + expect(preview({ field: field({ type: 'relation', emptyOption: 'No team' } as Partial), value: null }).find('[data-empty]').text()).toBe('No team') + }) + + it('shows a multiple relation as chips with initials and no remove buttons', () => { + const many = field({ type: 'relation', multiple: true } as Partial) + const wrapper = preview({ + field: many, + value: [5, 6], + labels: [ + { value: 5, label: 'team leads' }, + { value: 6, label: 'news' }, + ], + }) + const chips = wrapper.findAll('[data-chip]') + expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['team leads', 'news']) + expect(chips.map((chip) => chip.find('[aria-hidden="true"]').text())).toEqual(['TL', 'N']) + expect(wrapper.find('button').exists()).toBe(false) + expect(preview({ field: many, value: [] }).find('[data-empty]').text()).toBe('—') + // Labels without a list value are not shown. + expect(preview({ field: many, value: null, labels: [{ value: 5, label: 'x' }] }).find('[data-chip]').exists()).toBe(false) + }) + + it('renders a permission editor through its own control, read-only', () => { + const permissions = clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!) + const wrapper = preview({ field: permissions, value: { 'posts.edit': 1, 'reports.export': -1 } }) + expect(wrapper.find('[data-permission-editor]').exists()).toBe(true) + expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true) + // A read-only screen shows no lock and no "you cannot change" text. + expect(wrapper.find('[data-permission-locked]').exists()).toBe(false) + expect(wrapper.find('[data-locked]').exists()).toBe(false) + // The field passed on is a copy: the schema's own field is not marked. + expect(permissions.readOnly).toBeFalsy() + }) + + it('uses the id prefix it is given', () => { + const wrapper = mount(PreviewField, { props: { field: field({}), value: 'x', idPrefix: 'p2' } }) + expect(wrapper.find('dt').attributes('id')).toBe('p2-note-label') + }) +}) diff --git a/admin/tests/form/RecordActions.test.ts b/admin/tests/form/RecordActions.test.ts new file mode 100644 index 0000000..b0cc0e2 --- /dev/null +++ b/admin/tests/form/RecordActions.test.ts @@ -0,0 +1,186 @@ +// Record actions (UI-SPEC S2, D-10), the component on its own. Fixtures are +// neutral acme.roster.* data; no application names appear in framework tests. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' +import RecordActions from '../../src/components/form/RecordActions.vue' +import { toasts } from '../../src/state/useToasts' +import { langFixture } from '../fixtures/typed' +import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers' + +const RECORD = `${API}/acme/roster/people/4` +const source = { vendor: 'acme', plugin: 'roster', controller: 'people' } +const actions = [ + { name: 'reinstate', label: 'Reinstate', confirm: 'Lift the ban on this person?' }, + { name: 'activate', label: 'Activate' }, + { name: 'markup', label: 'Bold' }, +] +const strings = { + 'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' }, + 'backend::lang.form.action_done': { other: 'Action completed.' }, + 'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' }, + 'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' }, + 'backend::lang.extension.action_failed': { other: 'The action could not be completed.' }, +} + +const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } }) +const failure = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } }) + +function mountActions(routes: Record, props: { actions?: typeof actions; disabled?: boolean } = {}) { + const calls = mockApi(routes) + const wrapper = mount(RecordActions, { + props: { source, recordId: 4, actions: props.actions ?? actions, disabled: props.disabled ?? false }, + attachTo: document.body, + }) + return { wrapper, calls } +} + +const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`) +const dialog = () => document.body.querySelector('[data-confirm-dialog]') + +async function run(wrapper: VueWrapper, name: string, answer: 'confirm' | 'cancel' = 'confirm'): Promise { + await button(wrapper, name).trigger('click') + await flushPromises() + dialog()!.querySelector(`[data-action="${answer}"]`)!.click() + await flushPromises() +} + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('record actions component (UI-SPEC S2, D-10)', () => { + it('renders the offered actions in the given order, labels as text', () => { + const { wrapper } = mountActions({}) + const buttons = wrapper.findAll('[data-record-action]') + expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup']) + expect(buttons[2]!.text()).toBe('Bold') + expect(buttons[2]!.find('b').exists()).toBe(false) + expect(buttons.every((item) => item.classes().includes('border-border-strong'))).toBe(true) + // No primary button here: the host owns the one primary action. + expect(wrapper.find('.bg-primary').exists()).toBe(false) + }) + + it('renders nothing, not even a dialog host, without actions', () => { + const { wrapper } = mountActions({}, { actions: [] }) + expect(wrapper.find('[data-record-action]').exists()).toBe(false) + expect(wrapper.html()).not.toContain('button') + }) + + it('asks with the action confirm text, or the default naming the action', async () => { + const { wrapper } = mountActions({}) + await button(wrapper, 'reinstate').trigger('click') + await flushPromises() + expect(dialog()!.textContent).toContain('Lift the ban on this person?') + expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Reinstate') + dialog()!.querySelector('[data-action="cancel"]')!.click() + await flushPromises() + + await button(wrapper, 'activate').trigger('click') + await flushPromises() + expect(dialog()!.textContent).toContain('Run “Activate” on this record?') + }) + + it('posts an empty object to the action route of the record and emits done with the server message', async () => { + const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') }) + await run(wrapper, 'activate') + const posts = requestsTo(calls, 'POST', `${RECORD}/actions/activate`) + expect(posts).toHaveLength(1) + expect(await posts[0]!.json()).toEqual({}) + expect(wrapper.emitted('done')).toEqual([['The person was activated.']]) + expect(wrapper.emitted('busy')).toEqual([[true], [false]]) + expect(toasts.value).toHaveLength(0) + }) + + it('emits done with the default text when the server sends no message', async () => { + const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('') }) + await run(wrapper, 'activate') + expect(wrapper.emitted('done')).toEqual([['Action completed.']]) + }) + + it('sends nothing and emits nothing on cancel', async () => { + const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('x') }) + await run(wrapper, 'activate', 'cancel') + expect(calls).toHaveLength(0) + expect(wrapper.emitted()).toEqual({}) + expect(dialog()).toBeNull() + }) + + it('keeps the dialog open and busy and every button disabled until the request settles', async () => { + let release: (reply: Reply) => void = () => undefined + const pending = new Promise((resolve) => { + release = resolve + }) + const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: () => pending }) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + dialog()!.querySelector('[data-action="confirm"]')!.click() + await flushPromises() + + expect(dialog()).not.toBeNull() + expect(dialog()!.querySelector('[data-action="confirm"]')!.disabled).toBe(true) + expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true) + expect(wrapper.emitted('busy')).toEqual([[true]]) + expect(wrapper.emitted('done')).toBeUndefined() + + release(done('Done.')) + await flushPromises() + expect(dialog()).toBeNull() + expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true) + expect(wrapper.emitted('busy')).toEqual([[true], [false]]) + expect(wrapper.emitted('done')).toEqual([['Done.']]) + }) + + it('409: a danger toast and the stale event, no done', async () => { + const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(409, 'Conflict') }) + await run(wrapper, 'activate') + expect(wrapper.emitted('stale')).toHaveLength(1) + expect(wrapper.emitted('done')).toBeUndefined() + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies to this record.', 'danger']]) + }) + + it('404: the gone event and no toast', async () => { + const { wrapper } = mountActions({}) + await run(wrapper, 'activate') + expect(wrapper.emitted('gone')).toHaveLength(1) + expect(wrapper.emitted('stale')).toBeUndefined() + expect(toasts.value).toHaveLength(0) + }) + + it('403: a danger toast with the server message or the fallback, and no event', async () => { + const withMessage = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, 'Only team leads may activate people.') }) + await run(withMessage.wrapper, 'activate') + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Only team leads may activate people.', 'danger']]) + expect(Object.keys(withMessage.wrapper.emitted())).toEqual(['busy']) + withMessage.wrapper.unmount() + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') + + const bare = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, '') }) + await run(bare.wrapper, 'activate') + expect(toasts.value.map((toast) => toast.text)).toEqual(['You do not have permission to run this action.']) + }) + + it('any other failure: a danger toast with the server message or the fallback', async () => { + const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(500, '') }) + await run(wrapper, 'activate') + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The action could not be completed.', 'danger']]) + expect(wrapper.emitted('done')).toBeUndefined() + }) + + it('does nothing while the host disables it', async () => { + const { wrapper, calls } = mountActions({}, { disabled: true }) + expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + expect(dialog()).toBeNull() + expect(calls).toHaveLength(0) + }) +}) diff --git a/admin/tests/list/BulkActionsMenu.test.ts b/admin/tests/list/BulkActionsMenu.test.ts new file mode 100644 index 0000000..c46c577 --- /dev/null +++ b/admin/tests/list/BulkActionsMenu.test.ts @@ -0,0 +1,108 @@ +// Bulk actions menu (UI-SPEC S1, D-09), the component on its own. The whole +// flow with its confirmation is in tests/list/ListView.test.ts. Fixtures are +// neutral acme.* data; no application names appear in framework tests. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' +import BulkActionsMenu from '../../src/components/list/BulkActionsMenu.vue' +import { langFixture } from '../fixtures/typed' +import { resetState } from '../helpers' + +const LONG = 'Move the selected people to the archive of the previous season and notify their team leads' +const actions = [ + { name: 'activate', label: 'Activate', confirm: 'Activate the selected people?' }, + { name: 'archive', label: LONG }, + { name: 'markup', label: '' }, +] + +function mountMenu(props: { actions?: typeof actions; disabled?: boolean } = {}) { + return mount(BulkActionsMenu, { + props: { actions: props.actions ?? actions, disabled: props.disabled ?? false }, + attachTo: document.body, + }) +} + +const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]') +const items = () => Array.from(document.body.querySelectorAll('[data-bulk-menu] [data-bulk-action]')) + +async function open(wrapper: VueWrapper): Promise { + await trigger(wrapper).trigger('keydown', { key: 'Enter' }) + await flushPromises() +} + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, 'backend::lang.list.bulk_actions': { other: 'Bulk actions' } }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('bulk actions menu component (UI-SPEC S1, D-09)', () => { + it('is one outline trigger with its label and a decorative chevron', () => { + const wrapper = mountMenu() + expect(trigger(wrapper).text()).toBe('Bulk actions') + expect(trigger(wrapper).classes()).toContain('border-border-strong') + expect(trigger(wrapper).attributes('aria-haspopup')).toBe('menu') + expect(trigger(wrapper).find('svg').attributes('aria-hidden')).toBe('true') + expect(document.body.querySelector('[data-bulk-menu]')).toBeNull() + }) + + it('keeps its label while disabled and does not open', async () => { + const wrapper = mountMenu({ disabled: true }) + expect(trigger(wrapper).text()).toBe('Bulk actions') + expect(trigger(wrapper).attributes('disabled')).toBeDefined() + await open(wrapper) + expect(document.body.querySelector('[data-bulk-menu]')).toBeNull() + await wrapper.setProps({ disabled: false }) + expect(trigger(wrapper).attributes('disabled')).toBeUndefined() + }) + + it('lists the actions in the given order as menu items', async () => { + const wrapper = mountMenu() + await open(wrapper) + const menu = document.body.querySelector('[data-bulk-menu]')! + expect(menu.getAttribute('role')).toBe('menu') + expect(items().map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive', 'markup']) + expect(items().every((item) => item.getAttribute('role') === 'menuitem')).toBe(true) + // Declared order, not alphabetical: a reordered list renders reordered. + await wrapper.setProps({ actions: [actions[1]!, actions[0]!] }) + await flushPromises() + expect(items().map((item) => item.dataset.bulkAction)).toEqual(['archive', 'activate']) + }) + + it('lets a long label wrap inside the menu width and renders markup as text', async () => { + const wrapper = mountMenu() + await open(wrapper) + const menu = document.body.querySelector('[data-bulk-menu]')! + expect(Array.from(menu.classList)).toEqual(expect.arrayContaining(['w-[240px]', 'max-w-[320px]'])) + const [, long, markup] = items() + expect(long!.textContent?.trim()).toBe(LONG) + expect(long!.className).not.toMatch(/truncate|whitespace-nowrap|line-clamp/) + expect(long!.classList.contains('min-h-10')).toBe(true) + expect(markup!.textContent?.trim()).toBe('') + expect(markup!.querySelector('img')).toBeNull() + }) + + it('emits the chosen action name once and closes', async () => { + const wrapper = mountMenu() + await open(wrapper) + items()[1]!.click() + await flushPromises() + expect(wrapper.emitted('select')).toEqual([['archive']]) + expect(document.body.querySelector('[data-bulk-menu]')).toBeNull() + }) + + it('exposes focus(), which moves focus to the trigger', () => { + const wrapper = mountMenu() + const other = document.createElement('button') + document.body.appendChild(other) + other.focus() + expect(document.activeElement).toBe(other) + ;(wrapper.vm as unknown as { focus: () => void }).focus() + expect(document.activeElement).toBe(trigger(wrapper).element) + }) +}) diff --git a/admin/tests/list/RowStateBadges.test.ts b/admin/tests/list/RowStateBadges.test.ts new file mode 100644 index 0000000..38b9258 --- /dev/null +++ b/admin/tests/list/RowStateBadges.test.ts @@ -0,0 +1,68 @@ +// Row state badges (UI-SPEC S4, D-12). Fixtures are neutral; no application +// names appear in framework tests. +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import RowStateBadges from '../../src/components/list/RowStateBadges.vue' + +const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Not active' } +const badges = (states: readonly string[], use: Record = labels) => + mount(RowStateBadges, { props: { states, labels: use } }) + +describe('row state badges (UI-SPEC S4, D-12)', () => { + it.each([ + ['deleted', 'Deleted', ['border', 'border-border-strong', 'text-muted']], + ['negative', 'Blocked', ['bg-danger-soft', 'text-danger']], + ['disabled', 'Not active', ['bg-subtle', 'text-muted']], + ])('renders %s as a text badge with its own classes', (state, text, classes) => { + const wrapper = badges([state]) + const all = wrapper.findAll('[data-row-state]') + expect(all).toHaveLength(1) + expect(all[0]!.attributes('data-row-state')).toBe(state) + expect(all[0]!.text()).toBe(text) + expect(all[0]!.classes()).toEqual(expect.arrayContaining([...classes, 'rounded-pill', 'whitespace-nowrap', 'shrink-0'])) + // The state is said in text: colour alone would fail WCAG 1.4.1. + expect(all[0]!.classes()).not.toContain('sr-only') + expect(all[0]!.classes()).not.toContain('line-through') + }) + + it('renders combined states in the fixed order deleted, negative, disabled', () => { + for (const input of [ + ['disabled', 'negative', 'deleted'], + ['negative', 'deleted', 'disabled'], + ['deleted', 'negative', 'disabled'], + ]) { + const wrapper = badges(input) + expect(wrapper.findAll('[data-row-state]').map((badge) => badge.attributes('data-row-state'))).toEqual(['deleted', 'negative', 'disabled']) + } + expect(badges(['disabled', 'deleted']).findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Not active']) + }) + + it('renders a repeated state once', () => { + const wrapper = badges(['negative', 'negative', 'negative']) + expect(wrapper.findAll('[data-row-state]')).toHaveLength(1) + }) + + it('renders nothing at all without states', () => { + const wrapper = badges([]) + expect(wrapper.find('span').exists()).toBe(false) + expect(wrapper.text()).toBe('') + }) + + it('falls back to the state name when the schema sends no label for it', () => { + expect(badges(['negative'], {}).find('[data-row-state]').text()).toBe('negative') + }) + + it('backstop: a row state outside the fixed set renders no badge and no class', () => { + const hostile = ['starred', 'bg-danger-soft', 'deleted line-through', 'x', 'DELETED', '__proto__', 'constructor', ''] + const wrapper = badges(hostile, { ...labels, starred: 'Starred', constructor: 'Constructor' }) + expect(wrapper.find('[data-row-state]').exists()).toBe(false) + expect(wrapper.find('span').exists()).toBe(false) + expect(wrapper.html()).not.toMatch(/starred|bg-danger-soft|line-through|Starred|Constructor/) + + // Next to known states the unknown ones change nothing. + const mixed = badges(['starred', 'negative', 'x']) + expect(mixed.findAll('[data-row-state]').map((badge) => [badge.attributes('data-row-state'), badge.text()])).toEqual([['negative', 'Blocked']]) + expect(mixed.html()).not.toContain('starred') + expect(mixed.find('b').exists()).toBe(false) + }) +})