feat(10.1-02): mount plugin widget elements and run their actions from the form

- pluginAssets loads controller scripts and stylesheets from {base}/assets/ only, once per URL
- WidgetField mounts the custom element with attributes only and posts summer-action through the typed client
- Only declared fill keys returned by the server are patched; the form turns dirty and nothing saves
- widget is a registered valueless type rendered on create and update, labelled as a group
- backend::lang.extension strings in en and pl; embedded dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-29 02:04:34 +02:00
parent c3b76b1afb
commit 107d820109
19 changed files with 789 additions and 14 deletions

View File

@@ -34,6 +34,12 @@ export type FilterOption = Schemas['cabana.FilterOption']
export type RecordMeta = Schemas['cabana.RecordMeta']
export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
export type BulkResult = Schemas['cabana.BulkResult']
/** Body of a widget action: the record id (absent on create) and fill values. */
export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
/** A widget or toolbar action result: the toast message and fill values. */
export type AdminActionResult = Schemas['cabana.AdminActionResult']
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
export type ControllerAssets = Schemas['cabana.ControllerAssets']
/** One message as CLDR plural forms; "other" is always present (D-13). */
export type MessageForms = Schemas['cabana.MessageForms']
/** The public backend::lang bundle: full key to CLDR forms (D-20). */

View File

@@ -0,0 +1,110 @@
// Plugin scripts and stylesheets of admin controllers (D-14, D-16). A
// controller's list or form schema names its files as absolute URLs under
// {base}/assets/; they load when that controller's view opens, never
// earlier. Every script loads once per URL as a module script, and a script
// that fails to load is forgotten, so a later navigation can try again.
// Stylesheet links belong to one controller: opening another controller
// disables them, so plugin CSS never styles a view it was not written for.
// Files load only through script and link elements; nothing here fetches.
import type { ControllerAssets } from '../api/types'
import { runtime } from './runtime'
/** Attribute naming the controller that owns a plugin stylesheet link. */
export const OWNER_ATTRIBUTE = 'data-summer-controller'
const scripts = new Map<string, Promise<void>>()
// Links by controller and URL: two controllers of one plugin may share a
// file, and each keeps its own link so disabling one never hides the other.
const styles = new Map<string, { owner: string; link: HTMLLinkElement }>()
/** The URL prefix every plugin asset must start with. */
export function assetPrefix(): string {
return `${runtime.base}/assets/`
}
/**
* Whether a URL may load as a plugin asset: a same-origin path under
* {base}/assets/ with no dot segment, backslash, whitespace or control
* character (browsers strip or rewrite those before resolving).
*/
export function assetAllowed(url: string): boolean {
if (!url.startsWith(assetPrefix()) || url.includes('\\')) {
return false
}
for (const char of url) {
const code = char.codePointAt(0) ?? 0
if (code <= 0x20 || code === 0x7f) {
return false
}
}
const path = url.split(/[?#]/, 1)[0] ?? ''
return !path.split('/').some((segment) => segment === '.' || segment === '..')
}
/** Loads one module script; the same URL always yields the same promise. */
export function loadScript(url: string): Promise<void> {
if (!assetAllowed(url)) {
return Promise.reject(new Error(`plugin script outside ${assetPrefix()}: ${url}`))
}
const known = scripts.get(url)
if (known) {
return known
}
const loading = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.type = 'module'
script.addEventListener('load', () => resolve(), { once: true })
script.addEventListener(
'error',
() => {
scripts.delete(url)
script.remove()
reject(new Error(`plugin script failed to load: ${url}`))
},
{ once: true },
)
script.src = url
document.head.appendChild(script)
})
scripts.set(url, loading)
return loading
}
/** Adds a stylesheet link per new URL, owned by the controller. */
export function loadStyles(controllerId: string, urls: readonly string[]): void {
for (const url of urls) {
const key = `${controllerId}\n${url}`
if (!assetAllowed(url) || styles.has(key)) {
continue
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
link.href = url
styles.set(key, { owner: controllerId, link })
document.head.appendChild(link)
}
}
/** Enables this controller's stylesheet links and disables every other one. */
export function activateStyles(controllerId: string): void {
for (const { owner, link } of styles.values()) {
link.disabled = owner !== controllerId
}
}
/**
* Activates the controller's styles and loads its files. The promise
* resolves once every script has loaded or failed, with the URLs that
* failed; it never rejects. Views start this without awaiting it.
*/
export async function loadControllerAssets(
controllerId: string,
assets: ControllerAssets | null | undefined,
): Promise<string[]> {
activateStyles(controllerId)
loadStyles(controllerId, assets?.styles ?? [])
const urls = assets?.scripts ?? []
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
return urls.filter((_, index) => results[index]?.status === 'rejected')
}

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { ownsLabel } from './registry'
import { groupLabelled, ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
@@ -25,6 +25,9 @@ const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
// A widget or partial row labels a role="group" host, so its label is a span
// the host names with aria-labelledby, not a label for an input.
const labelsGroup = computed(() => groupLabelled(props.field.type))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
@@ -36,7 +39,10 @@ const describedBy = computed(() =>
<template>
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
<span v-if="labelsGroup" :id="`${controlId}-label`" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</span>
<label v-else-if="!selfLabelled" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<FieldRenderer

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../../../api/client'
import type { AdminRecord } from '../../../api/types'
import { currentLocale, t } from '../../../app/i18n'
import { showToast } from '../../../state/useToasts'
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
import type { FieldControlProps } from '../control'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_VALUES, WIDGET_EVENT, WIDGET_TIMEOUT } from '../formContext'
// A `type: widget` field (D-04, D-05, D-07, D-08; UI-SPEC S3). The plugin's
// custom element is created imperatively inside a node Vue never renders
// children into. It gets attributes only: record id, field name, locale, the
// current fill values as JSON and its labels; never a token, a cookie, a Vue
// instance or a function. It asks for its action with a bubbling
// summer-action event; the SPA posts it with the admin session, writes back
// only the declared fill keys the server returned and shows the message.
// Nothing is saved until the admin presses Save.
const props = defineProps<FieldControlProps>()
const values = inject(FORM_VALUES, null)
const patch = inject(FORM_PATCH, () => undefined)
const locale = inject(FORM_LOCALE, null)
const assets = inject(FORM_ASSETS, () => Promise.resolve([]))
const status = ref<'loading' | 'ready' | 'failed'>('loading')
const mountPoint = ref<HTMLElement | null>(null)
let element: HTMLElement | null = null
let busy = false
let unmounted = false
/** The current values of the field's fill keys; absent values are left out. */
function fillValues(): AdminRecord {
const current = values?.value ?? {}
const out: AdminRecord = {}
for (const name of props.field.fill ?? []) {
if (current[name] !== undefined) {
out[name] = current[name]
}
}
return out
}
const fillJson = computed(() => JSON.stringify(fillValues()))
const localeName = computed(() => locale?.value || currentLocale.value)
function timeout(ms: number): { promise: Promise<never>; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined
const promise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('widget element was not defined in time')), ms)
})
return { promise, cancel: () => clearTimeout(timer) }
}
async function defined(tag: string): Promise<void> {
const failed = await assets()
if (customElements.get(tag)) {
return
}
if (failed.length > 0) {
throw new Error(`plugin script failed: ${failed.join(', ')}`)
}
const limit = timeout(WIDGET_TIMEOUT)
try {
await Promise.race([customElements.whenDefined(tag), limit.promise])
} finally {
limit.cancel()
}
}
function fail(text?: string): void {
showToast(text || t('backend::lang.extension.action_failed'), 'danger')
element?.setAttribute('state', 'error')
}
async function onAction(): Promise<void> {
const source = props.source
if (!element || busy || !source) {
return
}
busy = true
element.setAttribute('busy', '')
element.removeAttribute('state')
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/widgets/{field}', {
params: { path: { ...source, field: props.field.name } },
body: { record_id: props.recordId ?? undefined, values: fillValues() },
})
if (result.data) {
const fill = result.data.data.fill ?? {}
for (const name of props.field.fill ?? []) {
if (Object.hasOwn(fill, name)) {
patch(name, fill[name])
}
}
if (result.data.data.message) {
showToast(result.data.data.message)
}
} else {
fail(result.error?.error.message)
}
} catch {
fail()
} finally {
busy = false
element?.removeAttribute('busy')
}
}
function listener(): void {
void onAction()
}
onMounted(async () => {
const tag = props.field.widget ?? ''
try {
if (tag === '' || !props.source) {
throw new Error('widget field without a tag or controller')
}
await defined(tag)
if (unmounted || !mountPoint.value) {
return
}
const created = document.createElement(tag)
created.setAttribute('record-id', props.recordId == null ? '' : String(props.recordId))
created.setAttribute('field-name', props.field.name)
created.setAttribute('locale', localeName.value)
created.setAttribute('fill-values', fillJson.value)
created.setAttribute('label', props.field.actionLabel || props.field.label || props.field.name)
created.setAttribute('busy-label', t('backend::lang.extension.busy'))
created.addEventListener(WIDGET_EVENT, listener)
mountPoint.value.append(created)
element = created
status.value = 'ready'
} catch {
if (!unmounted) {
status.value = 'failed'
}
}
})
watch(fillJson, (json) => element?.setAttribute('fill-values', json))
watch(localeName, (name) => element?.setAttribute('locale', name))
onBeforeUnmount(() => {
unmounted = true
element?.removeEventListener(WIDGET_EVENT, listener)
element = null
})
</script>
<template>
<ExtensionFailure
v-if="status === 'failed'"
:id="controlId"
data-widget-failed
:aria-describedby="describedBy || undefined"
:text="t('backend::lang.extension.widget_failed')"
/>
<div
v-else
:id="controlId"
role="group"
data-widget-host
:aria-labelledby="`${controlId}-label`"
:aria-describedby="describedBy || undefined"
:aria-busy="status === 'loading' ? 'true' : undefined"
class="flex min-h-input items-center"
>
<span v-if="status === 'loading'" data-widget-skeleton class="h-[42px] w-[160px] rounded-control bg-skel" aria-hidden="true" />
<div ref="mountPoint" class="contents" />
</div>
</template>

View File

@@ -0,0 +1,28 @@
// What a record form shares with extension controls (D-05, D-08). FormView
// provides these; a widget reads the current values and the locale, and
// writes action results back through patch, which marks the form dirty and
// clears that field's errors exactly like typing into it. A form without a
// provider (settings pages) gets the defaults of the injecting control.
import type { InjectionKey, Ref } from 'vue'
import type { AdminRecord } from '../../api/types'
/** Read-only view of the form's current values. */
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
/** Sets one field's value as if the admin had edited it. Nothing is saved. */
export const FORM_PATCH: InjectionKey<(name: string, value: unknown) => void> = Symbol('summer.form.patch')
/** The form schema's locale (meta.locale). */
export const FORM_LOCALE: InjectionKey<Readonly<Ref<string>>> = Symbol('summer.form.locale')
/**
* The form controller's plugin assets: resolves once its scripts have loaded
* or failed, with the failed URLs (pluginAssets.loadControllerAssets).
*/
export const FORM_ASSETS: InjectionKey<() => Promise<string[]>> = Symbol('summer.form.assets')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'
/** How long a widget waits for its custom element to be defined. */
export const WIDGET_TIMEOUT = 5000

View File

@@ -3,7 +3,9 @@
// dashed box, so an unknown type never breaks the form. The relation manager
// is registered like any control but holds no form value: it edits its
// relation through its own endpoints and renders only on an existing record.
// Phase 10.1 turns this seam into the plugin extension point.
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
// element (D-04, D-09). It holds no form value either, but it renders on
// create and update.
import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
@@ -14,6 +16,7 @@ import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
import WidgetField from './fields/WidgetField.vue'
// The control helpers live in ./control so the field components never import
// this module: registry -> field -> registry would be an import cycle whose
@@ -33,24 +36,34 @@ const renderers = new Map<string, Component>([
['checkbox', CheckboxField],
['relation', RelationField],
[RELATION_MANAGER, RelationManager],
['widget', WidgetField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
/**
* Types that need a saved record and hold no form value: never rendered on
* create, never part of the save body (D-05, design screen 5).
* Types that need a saved record: never rendered on create (D-05, design
* screen 5).
*/
const recordBound = new Set<string>([RELATION_MANAGER])
/** Types that hold no form value: never part of the save body (D-09). */
const valueless = new Set<string>([RELATION_MANAGER, 'widget'])
/**
* Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input.
*/
const groupLabelledTypes = new Set<string>(['widget'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type) && !recordBound.has(type)
return renderers.has(type) && !valueless.has(type)
}
/** Whether a field type renders only on an existing record (relation manager). */
@@ -61,3 +74,8 @@ export function needsRecord(type: string): boolean {
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
/** Whether a field type's label labels a group (widget) instead of a control. */
export function groupLabelled(type: string): boolean {
return groupLabelledTypes.has(type)
}

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { CircleAlert } from '@lucide/vue'
// The extension failure box (UI-SPEC S5): UnsupportedField's geometry with a
// danger icon, shown when a widget or partial could not be loaded. One line
// sits centred in the 44px box; longer text wraps, the box grows with 10px
// vertical padding and the icon stays on the first line.
defineProps<{ text: string }>()
</script>
<template>
<div
role="alert"
data-extension-failure
class="flex min-h-input items-center rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 py-2.5 text-[13px] text-muted"
>
<span class="flex min-w-0 items-start gap-2.5">
<CircleAlert :size="16" class="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
<span class="min-w-0 [overflow-wrap:anywhere]">{{ text }}</span>
</span>
</div>
</template>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watchEffect } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { currentLocale, message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_VALUES } from '../components/form/formContext'
import { needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
@@ -63,6 +65,8 @@ const confirm = useConfirm()
// Set right before a navigation the form itself starts after a save or a
// delete, so the dirty guard does not ask.
let leaving = false
// The controller's plugin scripts: settled when every one loaded or failed.
let assetsReady: Promise<string[]> = Promise.resolve([])
// A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
@@ -150,6 +154,10 @@ async function load(): Promise<void> {
])
schema.value = schemaResult.data?.data ?? null
failed.value = !schemaResult.data || (recordResult !== null && !recordResult.data)
if (schema.value) {
// Plugin files load in the background; only widgets wait for them (D-14).
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
}
if (schema.value && mode === 'create') {
values.value = initialValues(fields.value)
}
@@ -167,6 +175,14 @@ function update(name: string, value: unknown): void {
}
}
// Extension controls (widgets) read the values and locale and write action
// results back through update, so a fill marks the form dirty and clears
// that field's errors like an edit (D-07, D-08).
provide(FORM_VALUES, computed(() => values.value))
provide(FORM_PATCH, update)
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
provide(FORM_ASSETS, () => assetsReady)
/** 422: messages under fields, the first invalid field (schema order) focused. */
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)

View File

@@ -0,0 +1,68 @@
{
"data": {
"name": "Widget",
"fields": [
{
"name": "name",
"label": "Name",
"type": "text",
"span": "left",
"required": true
},
{
"name": "color",
"label": "Colour",
"type": "text",
"span": "right"
},
{
"name": "lookup",
"label": "Catalogue",
"type": "widget",
"comment": "Fills in the name. Save to keep it.",
"widget": "acme-demo-lookup",
"action": "lookup",
"actionLabel": "Look up",
"fill": ["name"]
}
],
"messages": {
"create": {
"other": "New widget"
},
"update": {
"other": "Editing widget"
},
"saved": {
"other": "Widget „:name” saved"
},
"deleteConfirm": {
"other": "Delete this widget?"
},
"deleted": {
"other": "Widget deleted"
}
},
"meta": {
"locale": "en"
},
"assets": {
"scripts": ["/admin-test/assets/acme/demo/js/lookup.js?v=abc"],
"styles": []
},
"redirects": {
"create": {
"redirect": "acme/demo/widgets/update/:id",
"redirectClose": "acme/demo/widgets"
},
"update": {
"redirect": "acme/demo/widgets",
"redirectClose": "acme/demo/widgets"
},
"default": "acme/demo/widgets"
}
},
"meta": {
"locale": "en"
}
}

View File

@@ -21,6 +21,18 @@
"backend::lang.auth.title": {
"other": "Witaj ponownie"
},
"backend::lang.extension.action_failed": {
"other": "Nie udało się wykonać akcji. Spróbuj ponownie."
},
"backend::lang.extension.busy": {
"other": "Wczytywanie…"
},
"backend::lang.extension.partial_failed": {
"other": "Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie."
},
"backend::lang.extension.widget_failed": {
"other": "Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki."
},
"backend::lang.form.add": {
"other": "Dodaj"
},

View File

@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
import langJson from './lang.json'
import navigationJson from './navigation.json'
import settingsJson from './settings.json'
import extensionFormSchemaJson from './extension.form-schema.json'
import formSchemaJson from './widgets.form-schema.json'
import listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json'
@@ -27,6 +28,8 @@ export const settingsFixture: {
values: S['cabana.Envelope-cabana_SettingsResult']
} = settingsJson
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
/** A form with a plugin widget and its controller script (Phase 10.1). */
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
export const listFixture: Rows = listJson
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
export const optionsFixture: {

View File

@@ -0,0 +1,302 @@
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
// custom element bridged to its action. Fixtures are neutral acme.demo.*
// data; no application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import {
activateStyles,
assetAllowed,
loadControllerAssets,
loadScript,
loadStyles,
OWNER_ATTRIBUTE,
} from '../../src/app/pluginAssets'
import {
FORM_ASSETS,
FORM_LOCALE,
FORM_PATCH,
FORM_VALUES,
WIDGET_EVENT,
WIDGET_TIMEOUT,
} from '../../src/components/form/formContext'
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
import { isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const WIDGET_ACTION = `${BASE}/widgets/lookup`
const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc'
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
// The plugin's element: plain JS, no Vue, defined before the form mounts as
// the plugin script would.
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
interface Seen {
scripts: string[]
links: HTMLLinkElement[]
}
/**
* Plugin files never load in happy-dom: a script "loads" on the next
* microtask (or errors when listed in failing) and links are only recorded.
*/
function stubAssets(failing: string[] = []): Seen {
const seen: Seen = { scripts: [], links: [] }
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) {
const src = node.getAttribute('src') ?? ''
seen.scripts.push(src)
queueMicrotask(() => node.dispatchEvent(new Event(failing.includes(src) ? 'error' : 'load')))
return node
}
if (node instanceof HTMLLinkElement) {
seen.links.push(node)
return node
}
return append(node)
})
return seen
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record },
[`PUT ${RECORD}`]: async (request) => ({
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
}),
...overrides,
}
}
/** A route whose reply the test releases, to observe the in-flight state. */
function deferred(): { route: Route; release: (reply: Reply) => void } {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
return { route: () => pending, release }
}
function widget(wrapper: VueWrapper): HTMLElement {
const element = wrapper.find('acme-demo-lookup').element
if (!(element instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return element
}
function act(element: HTMLElement): void {
element.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('plugin assets (D-14, D-16)', () => {
it('accepts only same-origin files under {base}/assets/', () => {
expect(assetAllowed(SCRIPT)).toBe(true)
for (const url of [
'https://cdn.example.test/admin-test/assets/x.js',
'//cdn.example.test/admin-test/assets/x.js',
'/admin-test/api/v1/x.js',
'/admin-test/assets/../api/v1/x.js',
'/admin-test/assets/a\\b.js',
'/admin-test/assets/a b.js',
'/other/assets/x.js',
]) {
expect(assetAllowed(url), url).toBe(false)
}
})
it('loads a script once per URL, refuses foreign URLs and retries after a failure', async () => {
const failing = '/admin-test/assets/acme/demo/js/broken.js?v=1'
const seen = stubAssets([failing])
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow()
const url = '/admin-test/assets/acme/demo/js/once.js?v=1'
const first = loadScript(url)
expect(loadScript(url)).toBe(first)
await first
await expect(loadScript(failing)).rejects.toThrow()
await expect(loadScript(failing)).rejects.toThrow()
expect(seen.scripts).toEqual([url, failing, failing])
expect(await loadControllerAssets('acme.demo.widgets', { scripts: [url, failing], styles: [] })).toEqual([failing])
})
it('owns stylesheet links per controller and disables the other controllers', () => {
const seen = stubAssets()
const css = '/admin-test/assets/acme/demo/css/demo.css?v=1'
loadStyles('acme.demo.one', [css, 'https://cdn.example.test/x.css'])
loadStyles('acme.demo.one', [css])
loadStyles('acme.demo.two', [css])
expect(seen.links).toHaveLength(2)
const [one, two] = seen.links
expect(one!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.one')
expect(one!.rel).toBe('stylesheet')
activateStyles('acme.demo.two')
expect(one!.disabled).toBe(true)
expect(two!.disabled).toBe(false)
activateStyles('acme.demo.one')
expect(one!.disabled).toBe(false)
expect(two!.disabled).toBe(true)
})
})
describe('widget field (D-04, D-05, D-07, D-08)', () => {
it('is a registered, valueless control that renders on create', () => {
expect(rendererFor('widget')).toBe(WidgetField)
expect(isRegistered('widget')).toBe(false)
expect(needsRecord('widget')).toBe(false)
expect(FORM_VALUES).not.toBe(FORM_PATCH)
expect(FORM_LOCALE).not.toBe(FORM_ASSETS)
expect(WIDGET_TIMEOUT).toBe(5000)
})
it('mounts the element with attributes only and keeps fill-values current', async () => {
const seen = stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
await flushPromises()
expect(seen.scripts).toContain(SCRIPT)
const host = wrapper.find('[data-widget-host]')
expect(host.attributes('role')).toBe('group')
expect(host.attributes('id')).toBe('field-lookup')
expect(host.attributes('aria-labelledby')).toBe('field-lookup-label')
expect(host.attributes('aria-describedby')).toBe('field-lookup-comment')
expect(host.attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('#field-lookup-label').text()).toBe('Catalogue')
expect(wrapper.find('label[for="field-lookup"]').exists()).toBe(false)
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
const element = widget(wrapper)
expect(element.getAttribute('record-id')).toBe('1')
expect(element.getAttribute('field-name')).toBe('lookup')
expect(element.getAttribute('locale')).toBe('en')
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
expect(element.getAttribute('label')).toBe('Look up')
expect(element.getAttribute('busy-label')).toBe('Wczytywanie…')
expect(element.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
expect(Object.keys(element)).toEqual([])
await wrapper.find('#field-name').setValue('Red widget')
expect(element.getAttribute('fill-values')).toBe('{"name":"Red widget"}')
})
it('posts the action once while busy and patches only the fill keys the server returned', async () => {
stubAssets()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
await flushPromises()
const element = widget(wrapper)
act(element)
await flushPromises()
expect(element.hasAttribute('busy')).toBe(true)
act(element)
await flushPromises()
const posts = requestsTo(calls, 'POST', WIDGET_ACTION)
expect(posts).toHaveLength(1)
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await posts[0]!.json()).toEqual({ record_id: 1, values: { name: 'Blue widget' } })
action.release({ body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', color: '#ff0000' } }, meta: {} } })
await flushPromises()
expect(element.hasAttribute('busy')).toBe(false)
expect(element.hasAttribute('state')).toBe(false)
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
expect((wrapper.find('#field-color').element as HTMLInputElement).value).toBe('#0000ff')
expect(wrapper.find('[role="status"]').text()).toContain('Name filled in')
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(await put!.json()).toEqual({ name: 'Catalogue widget', color: '#0000ff' })
})
it('toasts the server message, marks the element and leaves the values on failure', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`POST ${WIDGET_ACTION}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } },
},
}),
)
await flushPromises()
const element = widget(wrapper)
act(element)
await flushPromises()
expect(element.getAttribute('state')).toBe('error')
expect(element.hasAttribute('busy')).toBe(false)
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Catalogue unreachable.')
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Blue widget')
})
it('falls back to the framework message when the error has none', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`POST ${WIDGET_ACTION}`]: { status: 500, body: {} } }),
)
await flushPromises()
act(widget(wrapper))
await flushPromises()
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
})
it('renders on create with an empty record id and empty fill values', async () => {
stubAssets()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
await flushPromises()
const element = widget(wrapper)
expect(element.getAttribute('record-id')).toBe('')
expect(element.getAttribute('fill-values')).toBe('{}')
act(element)
await flushPromises()
expect(await requestsTo(calls, 'POST', WIDGET_ACTION)[0]!.json()).toEqual({ values: {} })
action.release({ body: { data: { message: 'Done', fill: {} }, meta: {} } })
await flushPromises()
})
it('shows the failure box instead of the element when its script fails', async () => {
const schema = clone(extensionFormSchemaFixture)
const broken = '/admin-test/assets/acme/demo/js/missing.js?v=1'
schema.data.assets.scripts = [broken]
schema.data.fields = schema.data.fields.map((field) =>
field.name === 'lookup' ? { ...field, widget: 'acme-demo-missing' } : field,
)
stubAssets([broken])
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
await flushPromises()
const box = wrapper.find('[data-widget-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.attributes('id')).toBe('field-lookup')
expect(box.text()).toBe('Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.')
expect(wrapper.find('acme-demo-missing').exists()).toBe(false)
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BAlwlQ8W.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CLf0gZ3D.css">
<script type="module" crossorigin src="./assets/index-CnkxaoJL.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DQRkkGSM.css">
</head>
<body>
<div id="app"></div>

View File

@@ -99,6 +99,11 @@ settings:
empty: No settings are available.
saved: Settings saved
back: Back to settings
extension:
busy: Loading…
widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets.
partial_failed: This section could not be loaded. Refresh the page to try again.
action_failed: The action could not be completed. Please try again.
messages:
list:
record_count:

View File

@@ -107,6 +107,11 @@ settings:
empty: Brak dostępnych ustawień.
saved: Zapisano ustawienia
back: Wróć do ustawień
extension:
busy: Wczytywanie…
widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.
partial_failed: Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.
action_failed: Nie udało się wykonać akcji. Spróbuj ponownie.
messages:
list:
record_count: