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:
175
admin/src/components/form/fields/PluginField.vue
Normal file
175
admin/src/components/form/fields/PluginField.vue
Normal 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>
|
||||
Reference in New Issue
Block a user