feat(16.2-01): host plugin field type elements in the admin SPA

- loadFieldTypes fetches GET /field-types after sign-in, loads each script
  once and adds the styles as global links activateStyles never disables
- PluginField creates the type's element, sets the value property and the
  contract attributes, takes summer-change as the form value and never
  writes back a value the element just emitted
- rendererForField and isRegisteredField route any field with an element
  to PluginField in forms, previews and the save payload
- rebuilt modules/boardwalk/dist
This commit is contained in:
2026-10-10 11:10:41 +02:00
parent 717d46ec3d
commit 83fa2f7b2c
16 changed files with 471 additions and 30 deletions

View File

@@ -54,6 +54,8 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
export type AdminActionResult = Schemas['cabana.AdminActionResult']
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
export type ControllerAssets = Schemas['cabana.ControllerAssets']
/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
/** A registered toolbar action the admin may run, with its localized label. */
export type ToolbarAction = Schemas['cabana.ToolbarAction']
/** One node of a rendered partial: an allowlisted element or a text node. */

View File

@@ -0,0 +1,52 @@
// Plugin field types (pact.HasFormFieldTypes, D-13). After sign-in the SPA
// asks GET {api}/field-types once and loads every type's scripts as module
// scripts (each URL once, through loadScript) and its stylesheets as global
// links that no navigation disables, so a type's element works in any
// controller's form. Nothing here ever rejects: a script that fails to load
// surfaces later as that field's failure state.
import { api } from '../api/client'
import type { FieldTypeAssets } from '../api/types'
import { loadGlobalStyles, loadScript } from './pluginAssets'
// Element tag -> the script URLs of its type that failed to load.
const scriptsByElement = new Map<string, Promise<string[]>>()
/**
* Fetches the registered field types, starts loading their scripts and
* enables their styles. Resolves with the list (empty on any failure)
* without waiting for the scripts.
*/
export async function loadFieldTypes(): Promise<FieldTypeAssets[]> {
let types: FieldTypeAssets[] = []
try {
const { data, response } = await api.GET('/field-types')
types = response.ok && data ? data.data : []
} catch {
return []
}
for (const type of types) {
loadGlobalStyles(type.styles ?? [])
const urls = type.scripts ?? []
scriptsByElement.set(
type.element,
Promise.allSettled(urls.map((url) => loadScript(url))).then((results) =>
urls.filter((_, index) => results[index]?.status === 'rejected'),
),
)
}
return types
}
/**
* Resolves once the scripts of the field type that defines `element` have
* loaded or failed, with the URLs that failed; at once with none when the
* element belongs to no loaded field type.
*/
export function fieldTypeScripts(element: string): Promise<string[]> {
return scriptsByElement.get(element) ?? Promise.resolve([])
}
/** Forgets the loaded field types (tests and a new session). */
export function resetFieldTypes(): void {
scriptsByElement.clear()
}

View File

@@ -7,7 +7,10 @@
// every navigation to another controller or to a screen that is not a
// controller view, so plugin CSS never styles a view it was not written for.
// Only the router picks the enabled controller; a schema response that
// arrives after its view was left adds its links disabled.
// arrives after its view was left adds its links disabled. Stylesheets of
// plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's
// element can appear in any controller's form, so its links are never
// disabled on navigation.
// Files load only through script and link elements; nothing here fetches.
import type { ControllerAssets } from '../api/types'
import { runtime } from './runtime'
@@ -15,6 +18,9 @@ import { runtime } from './runtime'
/** Attribute naming the controller that owns a plugin stylesheet link. */
export const OWNER_ATTRIBUTE = 'data-summer-controller'
/** Owner of stylesheet links that stay enabled on every screen (field types). */
export const GLOBAL_STYLE_OWNER = '*'
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.
@@ -90,7 +96,7 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
link.disabled = controllerId !== active
link.disabled = !styleEnabled(controllerId)
link.href = url
styles.set(key, { owner: controllerId, link })
document.head.appendChild(link)
@@ -105,10 +111,23 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
export function activateStyles(controllerId: string): void {
active = controllerId
for (const { owner, link } of styles.values()) {
link.disabled = owner !== active
link.disabled = !styleEnabled(owner)
}
}
/** Whether links of this owner are enabled now: the active controller's and the global ones. */
function styleEnabled(owner: string): boolean {
return owner === GLOBAL_STYLE_OWNER || owner === active
}
/**
* Adds stylesheet links that stay enabled on every screen: the styles of
* plugin field types, whose elements may render in any controller's form.
*/
export function loadGlobalStyles(urls: readonly string[]): void {
loadStyles(GLOBAL_STYLE_OWNER, urls)
}
/**
* Adds the controller's stylesheet links and loads its scripts. The links
* are enabled only while the router shows that controller, so a response

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rendererFor, type FieldControlProps } from './registry'
import { rendererForField, type FieldControlProps } from './registry'
// Picks the control for field.type from the registry (D-05) and forwards the
// Picks the control for the field from the registry (D-05; a plugin field
// type's element host when the field names an element) and forwards the
// shared control props and the value update.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const control = computed(() => rendererFor(props.field.type))
const control = computed(() => rendererForField(props.field))
</script>
<template>

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 { groupLabelled, ownsLabel } from './registry'
import { groupLabelled, isPluginField, 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,9 +25,10 @@ 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))
// A widget, partial or plugin field type 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) || isPluginField(props.field))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>

View File

@@ -3,13 +3,14 @@ import { computed } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import { t } from '../../app/i18n'
import { toggleOn } from './control'
import { rendererFor } from './registry'
import { isPluginField, rendererForField } from './registry'
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
// contrast and are read in order. Controls that already have a read-only
// mode (datepicker, fileupload, permissioneditor) and partials are rendered
// through their own component with the field marked read-only.
// mode (datepicker, fileupload, permissioneditor), partials and plugin field
// types (their element gets `disabled`) are rendered through their own
// component with the field marked read-only.
const props = defineProps<{
field: FormField
value: unknown
@@ -27,7 +28,7 @@ const labelId = computed(() => `${controlId.value}-label`)
const kind = computed(() => {
const type = props.field.type
if (ownControl.has(type)) {
if (ownControl.has(type) || isPluginField(props.field)) {
return 'control'
}
if (type === 'checkbox' || type === 'switch') {
@@ -41,7 +42,7 @@ const kind = computed(() => {
/** The field as its own control sees it on preview: always read-only. */
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
const control = computed(() => rendererFor(props.field.type))
const control = computed(() => rendererForField(props.field))
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fieldTypeScripts } from '../../../app/fieldTypes'
import { currentLocale, t } from '../../../app/i18n'
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
import type { FieldControlProps } from '../control'
import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
// A field whose type a plugin registers (pact.HasFormFieldTypes, D-13). The
// type's custom element is created imperatively inside a node Vue never
// renders children into. The SPA hands it the field value through the
// `value` property and plain attributes (field-name, record-id, locale,
// ui-locale, options, disabled, invalid); never a token, a cookie, a
// function or a Vue instance. The element reports an edit with a bubbling
// summer-change event whose detail.value becomes the form value. The SPA
// sets `value` only when the form value differs from the element's current
// value, so the value the element just emitted never resets it.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const formLocale = inject(FORM_LOCALE, null)
/** The plugin element: an HTMLElement with a `value` property. */
type ValueElement = HTMLElement & { value?: unknown }
const status = ref<'loading' | 'ready' | 'failed'>('loading')
const mountPoint = ref<HTMLElement | null>(null)
let element: ValueElement | null = null
let unmounted = false
const uiLocale = computed(() => formLocale?.value || currentLocale.value)
const optionsJson = computed(() => JSON.stringify(props.field.typeOptions ?? {}))
const disabled = computed(() => props.field.readOnly === true)
/** Equal values: the same primitive, or objects with the same JSON. */
function sameValue(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true
}
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false
}
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
function syncValue(target: ValueElement, value: unknown): void {
if (!sameValue(target.value, value)) {
target.value = value
}
}
function toggle(target: HTMLElement, name: string, on: boolean): void {
if (on) {
target.setAttribute(name, '')
} else {
target.removeAttribute(name)
}
}
function applyAttributes(target: HTMLElement): void {
target.setAttribute('field-name', props.field.name)
target.setAttribute('record-id', props.recordId == null ? '' : String(props.recordId))
target.setAttribute('locale', '')
target.setAttribute('ui-locale', uiLocale.value)
target.setAttribute('options', optionsJson.value)
toggle(target, 'disabled', disabled.value)
toggle(target, 'invalid', props.invalid === true)
}
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('field type element was not defined in time')), ms)
})
return { promise, cancel: () => clearTimeout(timer) }
}
async function defined(tag: string): Promise<void> {
const failed = await fieldTypeScripts(tag)
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 onChange(event: Event): void {
const detail: unknown = (event as CustomEvent<unknown>).detail
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'value')) {
emit('update:modelValue', (detail as { value: unknown }).value)
}
}
onMounted(async () => {
const tag = props.field.element ?? ''
try {
if (tag === '') {
throw new Error('plugin field without an element')
}
await defined(tag)
if (unmounted || !mountPoint.value) {
return
}
const created = document.createElement(tag) as ValueElement
applyAttributes(created)
syncValue(created, props.modelValue)
created.addEventListener(FIELD_CHANGE_EVENT, onChange)
mountPoint.value.append(created)
element = created
status.value = 'ready'
} catch {
if (!unmounted) {
status.value = 'failed'
}
}
})
watch(
() => props.modelValue,
(value) => {
if (element) {
syncValue(element, value)
}
},
{ deep: true },
)
watch(
() => [props.recordId, props.invalid, disabled.value, uiLocale.value, optionsJson.value],
() => {
if (element) {
applyAttributes(element)
}
},
)
onBeforeUnmount(() => {
unmounted = true
element?.removeEventListener(FIELD_CHANGE_EVENT, onChange)
element = null
})
</script>
<template>
<ExtensionFailure
v-if="status === 'failed'"
:id="controlId"
data-plugin-field-failed
:aria-describedby="describedBy || undefined"
:text="t('backend::lang.extension.widget_failed')"
/>
<div
v-else
:id="controlId"
role="group"
data-plugin-field
:aria-labelledby="field.label ? `${controlId}-label` : undefined"
:aria-describedby="describedBy || undefined"
:aria-busy="status === 'loading' ? 'true' : undefined"
class="flex min-h-input min-w-0 flex-col"
>
<span v-if="status === 'loading'" data-plugin-field-skeleton class="h-[42px] w-full rounded-control bg-skel" aria-hidden="true" />
<div ref="mountPoint" class="contents" />
</div>
</template>

View File

@@ -92,5 +92,11 @@ export const WIDGET_EVENT = 'summer-action'
*/
export const WIDGET_RESULT_EVENT = 'summer-result'
/**
* Event a plugin field type's element dispatches (bubbling) when the
* administrator edits its value; detail.value is the new form value.
*/
export const FIELD_CHANGE_EVENT = 'summer-change'
/** How long a widget waits for its custom element to be defined. */
export const WIDGET_TIMEOUT = 5000

View File

@@ -3,7 +3,7 @@
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { permissionValues } from './control'
import { isMLFieldType, localeRecord } from './mlLocale'
import { isRegistered } from './registry'
import { isRegisteredField } from './registry'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
@@ -53,7 +53,7 @@ export function contextAllows(field: FormField, mode: FormMode): boolean {
export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) {
if (field.readOnly || !isRegisteredField(field)) {
continue
}
const value = values[field.name]

View File

@@ -17,7 +17,12 @@
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
// locale-switching wrapper, MLField, around the text, textarea and markdown
// controls; each holds a locale -> text map, labelled as a group.
// Phase 16.2 adds plugin field types (D-13): a field whose schema carries an
// `element` renders PluginField whatever its type alias, holds a form value
// and is labelled as a group. Look such fields up with rendererForField and
// isRegisteredField, which know the field, not only its type.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
import DropdownField from './fields/DropdownField.vue'
@@ -25,6 +30,7 @@ import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
@@ -101,6 +107,24 @@ export function isRegistered(type: string): boolean {
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
}
/** Whether a field's type is registered by a plugin: its schema names an element. */
export function isPluginField(field: Pick<FormField, 'element'>): boolean {
return typeof field.element === 'string' && field.element !== ''
}
/** The control of one field: a plugin field type's host, else rendererFor its type. */
export function rendererForField(field: FormField): Component {
if (isPluginField(field)) {
return PluginField
}
return rendererFor(field.type)
}
/** Whether the SPA edits this field's value: a plugin field type always does. */
export function isRegisteredField(field: FormField): boolean {
return isPluginField(field) || isRegistered(field.type)
}
/** Whether a field type renders only on an existing record (relation manager). */
export function needsRecord(type: string): boolean {
return recordBound.has(type)

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { loadFieldTypes } from './app/fieldTypes'
import { loadStrings } from './app/i18n'
import { applyColorScheme } from './app/theme'
import { expireSession, me } from './state/useAuth'
@@ -13,13 +14,16 @@ async function boot(): Promise<void> {
// Dark mode follows the system preference before anything renders (A6).
applyColorScheme()
// Boot order (research Pattern 7): the public strings bundle first, so the
// login screen is translated, then the session, then navigation.
// login screen is translated, then the session, then navigation and the
// plugin field types.
await loadStrings().catch(() => undefined)
// A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)
if (user) {
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
// Plugin field types load once per session: their scripts define the
// elements any controller's form may use, their styles stay enabled.
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => []), loadFieldTypes()])
}
const router = createAdminRouter()
// A session that cannot be recovered ends here: the profile and every

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CircleAlert, Sun } from '@lucide/vue'
import { loadFieldTypes } from '../app/fieldTypes'
import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
@@ -29,7 +30,7 @@ async function submit(): Promise<void> {
return
}
password.value = ''
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
await Promise.all([loadNavigation(), loadSettings().catch(() => []), loadFieldTypes()])
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false