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)