feat(10.1-02): mount plugin widget elements and run their actions from the form
- pluginAssets loads controller scripts and stylesheets from {base}/assets/ only, once per URL
- WidgetField mounts the custom element with attributes only and posts summer-action through the typed client
- Only declared fill keys returned by the server are patched; the form turns dirty and nothing saves
- widget is a registered valueless type rendered on create and update, labelled as a group
- backend::lang.extension strings in en and pl; embedded dist rebuilt
This commit is contained in:
173
admin/src/components/form/fields/WidgetField.vue
Normal file
173
admin/src/components/form/fields/WidgetField.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<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_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; never a token, a cookie, a Vue
|
||||
// instance or a function. It asks for its action with a bubbling
|
||||
// summer-action event; the SPA posts it with the admin session, writes back
|
||||
// only the declared fill keys the server returned 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(): Promise<void> {
|
||||
const source = props.source
|
||||
if (!element || busy || !source) {
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
element.setAttribute('busy', '')
|
||||
element.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() },
|
||||
})
|
||||
if (result.data) {
|
||||
const fill = result.data.data.fill ?? {}
|
||||
for (const name of props.field.fill ?? []) {
|
||||
if (Object.hasOwn(fill, name)) {
|
||||
patch(name, fill[name])
|
||||
}
|
||||
}
|
||||
if (result.data.data.message) {
|
||||
showToast(result.data.data.message)
|
||||
}
|
||||
} else {
|
||||
fail(result.error?.error.message)
|
||||
}
|
||||
} catch {
|
||||
fail()
|
||||
} finally {
|
||||
busy = false
|
||||
element?.removeAttribute('busy')
|
||||
}
|
||||
}
|
||||
|
||||
function listener(): void {
|
||||
void onAction()
|
||||
}
|
||||
|
||||
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)
|
||||
mountPoint.value.append(created)
|
||||
element = 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="`${controlId}-label`"
|
||||
: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>
|
||||
Reference in New Issue
Block a user