Files
summercms/admin/src/components/form/fields/WidgetField.vue
Jakub Zych d660cd05fa fix(32): CR-04 take widget actions dispatched from connectedCallback
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.
2026-10-27 15:25:24 +01:00

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>