test(16.2-06): admin SPA coverage of plugin field types and View site

- fieldTypes: one load per script URL, failed and refused scripts
  reported, global styles enabled on every screen, empty list on failure
- registry, formState, FormField and ListView: plugin renderers, locale
  maps, notices as literal text, unknown column types as text
- AppShell: the View site link's href, target, rel and en/pl label
This commit is contained in:
2026-10-10 16:03:07 +02:00
parent 3404a4b3ad
commit 9f5e807af6
6 changed files with 322 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
import FormField from '../../src/components/form/FormField.vue'
import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed'
import { mockApi, resetState } from '../helpers'
import type { FormField as FormFieldSchema } from '../../src/api/types'
beforeEach(() => {
resetState()
@@ -32,6 +33,40 @@ describe('save notices', () => {
})
})
describe('plugin field rows (Phase 16.2)', () => {
const plugin: FormFieldSchema = { name: 'content', type: 'blogeditor', element: 'acme-demo-never-defined', label: 'Content', comment: 'Edited in the plugin element' }
it('labels the plugin element group with a span, never a label for an input', () => {
const wrapper = mount(FormField, { props: { field: plugin, modelValue: '', idPrefix: 'f' } })
expect(wrapper.find('label').exists()).toBe(false)
expect(wrapper.find('span#f-content-label').text()).toBe('Content')
const group = wrapper.find('[data-plugin-field]')
expect(group.attributes('aria-labelledby')).toBe('f-content-label')
expect(group.attributes('aria-describedby')).toBe('f-content-comment')
})
it('renders plugin notices as literal text linked to the group', () => {
const wrapper = mount(FormField, {
props: { field: plugin, modelValue: '', notices: ['EN: 1 element was removed: <script>', 'PL: Usunięto 1 atrybut: onclick'], idPrefix: 'f' },
})
const line = wrapper.find('#f-content-notice')
expect(line.attributes('role')).toBe('status')
expect(line.findAll('p').map((p) => p.text())).toEqual(['EN: 1 element was removed: <script>', 'PL: Usunięto 1 atrybut: onclick'])
expect(line.find('script').exists()).toBe(false)
expect(wrapper.find('[data-plugin-field]').attributes('aria-describedby')).toBe('f-content-comment f-content-notice')
})
it('shows notices and errors together, notices first', () => {
const wrapper = mount(FormField, {
props: { field: plugin, modelValue: '', notices: ['Trimmed'], errors: ['Required'], idPrefix: 'f' },
})
expect(wrapper.find('[data-plugin-field]').attributes('aria-describedby')).toBe('f-content-comment f-content-error f-content-notice')
const html = wrapper.html()
expect(html.indexOf('id="f-content-notice"')).toBeLessThan(html.indexOf('id="f-content-error"'))
expect(html.indexOf('id="f-content-notice"')).toBeGreaterThan(-1)
})
})
describe('form field', () => {
it('links the label to the control and hides the required asterisk from assistive tech', () => {
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })

View File

@@ -7,6 +7,7 @@ import {
fieldErrors,
focusField,
initialValues,
isMLField,
mergeMLValue,
noticeLines,
panelDomId,
@@ -324,3 +325,45 @@ describe('noticeLines', () => {
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
})
})
describe('plugin field values (Phase 16.2)', () => {
const plain: FormField = { name: 'notes', type: 'notes', element: 'acme-demo-notes' }
const translatable: FormField = { name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true }
it('treats only a translatable plugin field as a locale map', () => {
expect(isMLField(translatable)).toBe(true)
expect(isMLField(plain)).toBe(false)
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
expect(isMLField({ type: 'mltext' })).toBe(true)
expect(isMLField(undefined)).toBe(false)
})
it('seeds a translatable plugin field with every enabled locale, a plain one with nothing', () => {
expect(initialValues([translatable, plain], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
expect(initialValues([{ ...plain, default: '<p>x</p>' }], ['pl'])).toEqual({ notes: '<p>x</p>' })
})
it('sends a translatable value as a locale record and a plain value as it is', () => {
const values = { body: { pl: '<p>P</p>', en: '<p>E</p>', de: 7 }, notes: { blocks: [1] }, other: 'x' }
expect(editablePayload([translatable, plain], values)).toEqual({ body: { pl: '<p>P</p>', en: '<p>E</p>' }, notes: { blocks: [1] } })
expect(editablePayload([{ ...plain, readOnly: true }], values)).toEqual({})
expect(editablePayload([plain], {})).toEqual({})
})
it('groups notices per field, keeping their order, and skips malformed ones', () => {
const lines = noticeLines([
{ field: 'body', locale: 'en', message: 'second locale first' },
{ field: 'body', locale: 'pl', message: '1 element was removed: style' },
{ field: 'body', message: 'no locale' },
{ field: 'notes', locale: '', message: 'empty locale' },
{ field: '', message: 'no field name' },
null as unknown as { field: string; message: string },
{ field: 'notes', message: 42 as unknown as string },
])
expect(lines).toEqual({
body: ['EN: second locale first', 'PL: 1 element was removed: style', 'no locale'],
notes: ['empty locale'],
'': ['no field name'],
})
})
})

View File

@@ -15,16 +15,21 @@ import PasswordField from '../../src/components/form/fields/PasswordField.vue'
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import MLPluginField from '../../src/components/form/fields/MLPluginField.vue'
import PluginField from '../../src/components/form/fields/PluginField.vue'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import {
controlAttributes,
controlClass,
groupLabelled,
isPluginField,
isRegistered,
isRegisteredField,
needsRecord,
ownsLabel,
RELATION_MANAGER,
rendererFor,
rendererForField,
toggleOn,
toggleValue,
} from '../../src/components/form/registry'
@@ -220,3 +225,32 @@ describe('markdown and multilingual field types (Phase 14.2.1)', () => {
},
)
})
describe('plugin field types (Phase 16.2, D-13)', () => {
const plugin = { name: 'notes', type: 'notes', element: 'acme-demo-notes' }
it('renders a field that names an element with PluginField, a translatable one with MLPluginField', () => {
expect(rendererForField(plugin)).toBe(PluginField)
expect(rendererForField({ ...plugin, translatable: true })).toBe(MLPluginField)
expect(rendererForField({ ...plugin, translatable: false })).toBe(PluginField)
})
it('keeps the core renderer for a core type, and the unsupported box for an unknown type without an element', () => {
expect(rendererForField({ name: 'title', type: 'text' })).toBe(TextField)
expect(rendererForField({ name: 'body', type: 'markdown' })).toBe(MarkdownField)
expect(rendererForField({ name: 'x', type: 'notes' })).toBe(UnsupportedField)
expect(rendererForField({ name: 'x', type: 'notes', element: '' })).toBe(UnsupportedField)
})
it('counts a plugin field as registered and editable whatever its alias', () => {
expect(isPluginField(plugin)).toBe(true)
expect(isPluginField({ element: '' })).toBe(false)
expect(isPluginField({})).toBe(false)
expect(isRegisteredField(plugin)).toBe(true)
expect(isRegisteredField({ name: 'x', type: 'text' })).toBe(true)
expect(isRegisteredField({ name: 'x', type: 'notes' })).toBe(false)
// The alias is not a core type: only the element makes it a plugin field.
expect(isRegistered('notes')).toBe(false)
expect(groupLabelled('notes')).toBe(false)
})
})