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

@@ -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)