WidgetField assigned its element after append(), so an action the element dispatched from connectedCallback (inside append) found no target and was dropped without a result or an error state; widgets that load on connect never started. The element is now the action target before it is connected. Rebuilt the embedded admin dist.
205 lines
7.0 KiB
Vue
205 lines
7.0 KiB
Vue
<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_RESULT_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, and after a successful action
|
|
// the serialized response data as `data`; never a token, a cookie, a Vue
|
|
// instance or a function. It asks for its action with a bubbling
|
|
// summer-action event whose optional detail.payload (any JSON value) the SPA
|
|
// posts as `payload`; the SPA posts with the admin session, writes back only
|
|
// the declared fill keys the server returned, sets or removes the `data`
|
|
// attribute from the response data, dispatches summer-result on the element
|
|
// with detail {data, fill, message} 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(payload: unknown): Promise<void> {
|
|
const source = props.source
|
|
const target = element
|
|
if (!target || busy || !source) {
|
|
return
|
|
}
|
|
busy = true
|
|
target.setAttribute('busy', '')
|
|
target.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(),
|
|
...(payload === undefined ? {} : { payload }),
|
|
},
|
|
})
|
|
if (result.data) {
|
|
const answer = result.data.data
|
|
const fill = answer.fill ?? {}
|
|
for (const name of props.field.fill ?? []) {
|
|
if (Object.hasOwn(fill, name)) {
|
|
patch(name, fill[name])
|
|
}
|
|
}
|
|
if (!unmounted) {
|
|
if (Object.hasOwn(answer, 'data')) {
|
|
target.setAttribute('data', JSON.stringify(answer.data))
|
|
} else {
|
|
target.removeAttribute('data')
|
|
}
|
|
target.dispatchEvent(
|
|
new CustomEvent(WIDGET_RESULT_EVENT, { detail: { data: answer.data, fill, message: answer.message } }),
|
|
)
|
|
}
|
|
if (answer.message) {
|
|
showToast(answer.message)
|
|
}
|
|
} else {
|
|
fail(result.error?.error.message)
|
|
}
|
|
} catch {
|
|
fail()
|
|
} finally {
|
|
busy = false
|
|
element?.removeAttribute('busy')
|
|
}
|
|
}
|
|
|
|
/** The event's own payload property, or undefined when the detail has none. */
|
|
function payloadOf(event: Event): unknown {
|
|
const detail: unknown = (event as CustomEvent<unknown>).detail
|
|
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'payload')) {
|
|
return (detail as { payload: unknown }).payload
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function listener(event: Event): void {
|
|
void onAction(payloadOf(event))
|
|
}
|
|
|
|
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)
|
|
// The element is the action target before it is connected: an action
|
|
// its connectedCallback dispatches (inside append) is taken, not dropped.
|
|
element = created
|
|
mountPoint.value.append(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="field.label ? `${controlId}-label` : undefined"
|
|
: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>
|