Files
summercms/admin/tests/form/PreviewField.test.ts
Jakub Zych 84efdc09e0 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
2026-10-05 15:29:44 +02:00

155 lines
7.6 KiB
TypeScript

// 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>): 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: '<img src=x onerror=alert(1)>' })
expect(wrapper.find('[data-preview-value]').text()).toBe('<img src=x onerror=alert(1)>')
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<FormField>)
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<FormField>), 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<FormField>)
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')
})
})