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

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