test(12.1-05): unit tests for the Phase 12.1 SPA components
- BulkActionsMenu, RowStateBadges, RecordActions, PreviewField, FormErrorBanner, PasswordField and PermissionEditorField, each mounted on its own - backstop: a row state outside the fixed set renders no badge and no class - backstop: the permission editor's emission rules per mode, the locked row and codes outside the options
This commit is contained in:
218
admin/tests/form/PermissionEditorField.test.ts
Normal file
218
admin/tests/form/PermissionEditorField.test.ts
Normal file
@@ -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> = {}): 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<FormField>[]) {
|
||||
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<string, number>).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({})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user