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:
155
admin/tests/app/fieldTypes.test.ts
Normal file
155
admin/tests/app/fieldTypes.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Plugin field type loader (Phase 16.2 D-13): GET {api}/field-types once,
|
||||
// every script URL loaded once as a module script, styles enabled globally
|
||||
// on every screen, and nothing ever rejects. Files never load in happy-dom:
|
||||
// appended scripts and links go into a detached sandbox the test inspects,
|
||||
// and the test fires each script's load or error event itself. The asset
|
||||
// loader's state lives for the whole file, so every test uses its own URLs.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fieldTypeScripts, loadFieldTypes, resetFieldTypes } from '../../src/app/fieldTypes'
|
||||
import { activateStyles, GLOBAL_STYLE_OWNER, loadStyles, OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
|
||||
import { API, mockApi, type Route } from '../helpers'
|
||||
|
||||
let sandbox: HTMLDivElement
|
||||
|
||||
function captureHead(): void {
|
||||
sandbox = document.createElement('div')
|
||||
const head = document.head
|
||||
const append = head.appendChild.bind(head)
|
||||
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement || node instanceof HTMLLinkElement) {
|
||||
return sandbox.appendChild(node)
|
||||
}
|
||||
return append(node)
|
||||
})
|
||||
}
|
||||
|
||||
function scripts(src: string): HTMLScriptElement[] {
|
||||
return Array.from(sandbox.querySelectorAll('script')).filter((script) => script.getAttribute('src') === src)
|
||||
}
|
||||
|
||||
function links(href: string): HTMLLinkElement[] {
|
||||
return Array.from(sandbox.querySelectorAll('link')).filter((link) => link.getAttribute('href') === href)
|
||||
}
|
||||
|
||||
const asset = (file: string) => `/admin-test/assets/acme/demo/${file}`
|
||||
|
||||
function types(body: unknown): Record<string, Route> {
|
||||
return { [`GET ${API}/field-types`]: { body } }
|
||||
}
|
||||
|
||||
function entry(name: string, scriptUrls: string[], styleUrls: string[] = []) {
|
||||
return { name, element: `acme-demo-${name}`, translatable: false, scripts: scriptUrls, styles: styleUrls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetFieldTypes()
|
||||
captureHead()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sandbox.remove()
|
||||
})
|
||||
|
||||
describe('loadFieldTypes', () => {
|
||||
it('loads each script URL once, even across two runs and two types', async () => {
|
||||
const shared = asset('js/shared-once.js?v=111111111111')
|
||||
const own = asset('js/own-once.js?v=222222222222')
|
||||
const calls = mockApi(types({ data: [entry('alpha', [shared]), entry('beta', [shared, own])], meta: {} }))
|
||||
const first = await loadFieldTypes()
|
||||
const second = await loadFieldTypes()
|
||||
expect(first.map((t) => t.name)).toEqual(['alpha', 'beta'])
|
||||
expect(second).toEqual(first)
|
||||
expect(calls.map((request) => new URL(request.url).pathname)).toEqual([`${API}/field-types`, `${API}/field-types`])
|
||||
expect(scripts(shared)).toHaveLength(1)
|
||||
expect(scripts(own)).toHaveLength(1)
|
||||
expect(scripts(shared)[0]!.type).toBe('module')
|
||||
|
||||
scripts(shared)[0]!.dispatchEvent(new Event('load'))
|
||||
scripts(own)[0]!.dispatchEvent(new Event('load'))
|
||||
await expect(fieldTypeScripts('acme-demo-alpha')).resolves.toEqual([])
|
||||
await expect(fieldTypeScripts('acme-demo-beta')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('does not wait for the scripts, and reports the ones that failed', async () => {
|
||||
const ok = asset('js/ok-fail.js')
|
||||
const broken = asset('js/broken-fail.js')
|
||||
mockApi(types({ data: [entry('gamma', [ok, broken])], meta: {} }))
|
||||
const loaded = await loadFieldTypes()
|
||||
expect(loaded).toHaveLength(1)
|
||||
let settled = false
|
||||
const pending = fieldTypeScripts('acme-demo-gamma').then((failed) => {
|
||||
settled = true
|
||||
return failed
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
scripts(ok)[0]!.dispatchEvent(new Event('load'))
|
||||
scripts(broken)[0]!.dispatchEvent(new Event('error'))
|
||||
await expect(pending).resolves.toEqual([broken])
|
||||
})
|
||||
|
||||
it('refuses URLs outside the asset prefix without appending them', async () => {
|
||||
const outside = 'https://cdn.example.test/admin-test/assets/acme/demo/js/x.js'
|
||||
const climb = '/admin-test/assets/../api/v1/x.js'
|
||||
const style = 'https://cdn.example.test/x.css'
|
||||
mockApi(types({ data: [entry('delta', [outside, climb], [style])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
expect(sandbox.querySelectorAll('script')).toHaveLength(0)
|
||||
expect(links(style)).toHaveLength(0)
|
||||
await expect(fieldTypeScripts('acme-demo-delta')).resolves.toEqual([outside, climb])
|
||||
})
|
||||
|
||||
it('keeps field type styles enabled on every screen', async () => {
|
||||
const global = asset('css/global-type.css')
|
||||
const own = asset('css/controller-only.css')
|
||||
mockApi(types({ data: [entry('epsilon', [], [global])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
loadStyles('acme.demo.widgets', [own])
|
||||
const [globalLink] = links(global)
|
||||
const [ownLink] = links(own)
|
||||
expect(globalLink!.getAttribute(OWNER_ATTRIBUTE)).toBe(GLOBAL_STYLE_OWNER)
|
||||
expect(globalLink!.rel).toBe('stylesheet')
|
||||
for (const active of ['acme.demo.widgets', 'acme.demo.other', '']) {
|
||||
activateStyles(active)
|
||||
expect(globalLink!.disabled).toBe(false)
|
||||
expect(ownLink!.disabled).toBe(active !== 'acme.demo.widgets')
|
||||
}
|
||||
// A second load adds no second link.
|
||||
await loadFieldTypes()
|
||||
expect(links(global)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each<[string, () => void]>([
|
||||
['a 500 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 500, body: { error: { code: 'error', message: 'down', details: {} } } } })],
|
||||
['a 401 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } } })],
|
||||
['a network failure', () => vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))],
|
||||
])('resolves an empty list on %s', async (_name, setup) => {
|
||||
setup()
|
||||
await expect(loadFieldTypes()).resolves.toEqual([])
|
||||
expect(sandbox.querySelectorAll('script, link')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tolerates a type without script or style lists', async () => {
|
||||
mockApi(types({ data: [{ name: 'zeta', element: 'acme-demo-zeta', translatable: true }], meta: {} }))
|
||||
const loaded = await loadFieldTypes()
|
||||
expect(loaded.map((t) => t.name)).toEqual(['zeta'])
|
||||
await expect(fieldTypeScripts('acme-demo-zeta')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fieldTypeScripts', () => {
|
||||
it('resolves at once with no failures for an element no type defines', async () => {
|
||||
await expect(fieldTypeScripts('acme-demo-unknown')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('forgets the loaded types after resetFieldTypes', async () => {
|
||||
const url = asset('js/reset.js')
|
||||
mockApi(types({ data: [entry('eta', [url])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
scripts(url)[0]!.dispatchEvent(new Event('error'))
|
||||
await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([url])
|
||||
resetFieldTypes()
|
||||
await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,6 +143,21 @@ describe('list view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin field type columns (Phase 16.2)', () => {
|
||||
it('renders the row value of a column type it does not know as plain text', async () => {
|
||||
const schema = schemaWith((data) => {
|
||||
const code = data.columns.find((column) => column.key === 'code')!
|
||||
;(code as { type?: string }).type = 'blogeditor'
|
||||
})
|
||||
const rows = clone(listFixture)
|
||||
;(rows.data[0] as Record<string, unknown>).code = '<b>Grzyby</b> rosną'
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}/schema/list`]: schema, [`GET ${BASE}`]: { body: rows } }))
|
||||
const cell = wrapper.findAll('tbody tr')[0]!.findAll('td').find((td) => td.text().includes('Grzyby'))
|
||||
expect(cell?.text()).toBe('<b>Grzyby</b> rosną')
|
||||
expect(cell?.find('b').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () => {
|
||||
const STATS = `${BASE}/partials/stats`
|
||||
const RECOUNT = `${BASE}/toolbar/recount`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import AppShell from '../../src/components/shell/AppShell.vue'
|
||||
@@ -5,7 +7,9 @@ import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
|
||||
import { showToast } from '../../src/state/useToasts'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
async function mountShell(path: string) {
|
||||
await signIn()
|
||||
@@ -77,3 +81,39 @@ describe('app shell', () => {
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Broken')
|
||||
})
|
||||
})
|
||||
|
||||
describe('View site link (Phase 16.2 quick follow-up)', () => {
|
||||
it('opens the site root in a new tab without an opener or referrer', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
const link = wrapper.find('header a[data-view-site]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toBe(`${window.location.origin}/`)
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
expect(link.attributes('rel')?.split(' ').sort()).toEqual(['noopener', 'noreferrer'])
|
||||
// It sits next to the user menu, inside the header.
|
||||
expect(link.element.nextElementSibling?.matches('[data-user-menu], :has([data-user-menu])')).toBe(true)
|
||||
expect(link.find('svg').attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pl', 'Zobacz stronę'],
|
||||
['en', 'View site'],
|
||||
])('labels the link from the phrasebook in %s', async (locale, label) => {
|
||||
setBundle({ ...langFixture.data, 'backend::lang.nav.view_site': { other: label } }, locale)
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
const link = wrapper.find('a[data-view-site]')
|
||||
expect(link.attributes('aria-label')).toBe(label)
|
||||
expect(link.attributes('title')).toBe(label)
|
||||
})
|
||||
|
||||
it('has the label key in the backend phrasebook for en and pl', () => {
|
||||
for (const [locale, label] of [
|
||||
['en', 'View site'],
|
||||
['pl', 'Zobacz stronę'],
|
||||
]) {
|
||||
// vitest runs from the admin directory.
|
||||
const yaml = readFileSync(resolve(process.cwd(), '../modules/phrasebook/backend/lang', locale!, 'lang.yaml'), 'utf8')
|
||||
expect(yaml).toMatch(new RegExp(`^nav:[\\s\\S]*?^ view_site: ${label}$`, 'm'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user