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:
@@ -34,6 +34,12 @@ export type FilterOption = Schemas['cabana.FilterOption']
|
||||
export type RecordMeta = Schemas['cabana.RecordMeta']
|
||||
export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
|
||||
export type BulkResult = Schemas['cabana.BulkResult']
|
||||
/** Body of a widget action: the record id (absent on create) and fill values. */
|
||||
export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
/** A widget or toolbar action result: the toast message and fill values. */
|
||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||
/** One message as CLDR plural forms; "other" is always present (D-13). */
|
||||
export type MessageForms = Schemas['cabana.MessageForms']
|
||||
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
|
||||
|
||||
110
admin/src/app/pluginAssets.ts
Normal file
110
admin/src/app/pluginAssets.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// Plugin scripts and stylesheets of admin controllers (D-14, D-16). A
|
||||
// controller's list or form schema names its files as absolute URLs under
|
||||
// {base}/assets/; they load when that controller's view opens, never
|
||||
// earlier. Every script loads once per URL as a module script, and a script
|
||||
// that fails to load is forgotten, so a later navigation can try again.
|
||||
// Stylesheet links belong to one controller: opening another controller
|
||||
// disables them, so plugin CSS never styles a view it was not written for.
|
||||
// Files load only through script and link elements; nothing here fetches.
|
||||
import type { ControllerAssets } from '../api/types'
|
||||
import { runtime } from './runtime'
|
||||
|
||||
/** Attribute naming the controller that owns a plugin stylesheet link. */
|
||||
export const OWNER_ATTRIBUTE = 'data-summer-controller'
|
||||
|
||||
const scripts = new Map<string, Promise<void>>()
|
||||
// Links by controller and URL: two controllers of one plugin may share a
|
||||
// file, and each keeps its own link so disabling one never hides the other.
|
||||
const styles = new Map<string, { owner: string; link: HTMLLinkElement }>()
|
||||
|
||||
/** The URL prefix every plugin asset must start with. */
|
||||
export function assetPrefix(): string {
|
||||
return `${runtime.base}/assets/`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL may load as a plugin asset: a same-origin path under
|
||||
* {base}/assets/ with no dot segment, backslash, whitespace or control
|
||||
* character (browsers strip or rewrite those before resolving).
|
||||
*/
|
||||
export function assetAllowed(url: string): boolean {
|
||||
if (!url.startsWith(assetPrefix()) || url.includes('\\')) {
|
||||
return false
|
||||
}
|
||||
for (const char of url) {
|
||||
const code = char.codePointAt(0) ?? 0
|
||||
if (code <= 0x20 || code === 0x7f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const path = url.split(/[?#]/, 1)[0] ?? ''
|
||||
return !path.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
}
|
||||
|
||||
/** Loads one module script; the same URL always yields the same promise. */
|
||||
export function loadScript(url: string): Promise<void> {
|
||||
if (!assetAllowed(url)) {
|
||||
return Promise.reject(new Error(`plugin script outside ${assetPrefix()}: ${url}`))
|
||||
}
|
||||
const known = scripts.get(url)
|
||||
if (known) {
|
||||
return known
|
||||
}
|
||||
const loading = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.type = 'module'
|
||||
script.addEventListener('load', () => resolve(), { once: true })
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
scripts.delete(url)
|
||||
script.remove()
|
||||
reject(new Error(`plugin script failed to load: ${url}`))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
script.src = url
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
scripts.set(url, loading)
|
||||
return loading
|
||||
}
|
||||
|
||||
/** Adds a stylesheet link per new URL, owned by the controller. */
|
||||
export function loadStyles(controllerId: string, urls: readonly string[]): void {
|
||||
for (const url of urls) {
|
||||
const key = `${controllerId}\n${url}`
|
||||
if (!assetAllowed(url) || styles.has(key)) {
|
||||
continue
|
||||
}
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
|
||||
link.href = url
|
||||
styles.set(key, { owner: controllerId, link })
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enables this controller's stylesheet links and disables every other one. */
|
||||
export function activateStyles(controllerId: string): void {
|
||||
for (const { owner, link } of styles.values()) {
|
||||
link.disabled = owner !== controllerId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the controller's styles and loads its files. The promise
|
||||
* resolves once every script has loaded or failed, with the URLs that
|
||||
* failed; it never rejects. Views start this without awaiting it.
|
||||
*/
|
||||
export async function loadControllerAssets(
|
||||
controllerId: string,
|
||||
assets: ControllerAssets | null | undefined,
|
||||
): Promise<string[]> {
|
||||
activateStyles(controllerId)
|
||||
loadStyles(controllerId, assets?.styles ?? [])
|
||||
const urls = assets?.scripts ?? []
|
||||
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
||||
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import FieldRenderer from './FieldRenderer.vue'
|
||||
import { ownsLabel } from './registry'
|
||||
import { groupLabelled, ownsLabel } from './registry'
|
||||
|
||||
// One form row: label (600, red aria-hidden asterisk when required), the
|
||||
// control, the comment and the error line linked by aria-describedby.
|
||||
@@ -25,6 +25,9 @@ const commentId = computed(() => `${base.value}-comment`)
|
||||
const errorId = computed(() => `${base.value}-error`)
|
||||
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
|
||||
const selfLabelled = computed(() => ownsLabel(props.field.type))
|
||||
// A widget or partial row labels a role="group" host, so its label is a span
|
||||
// the host names with aria-labelledby, not a label for an input.
|
||||
const labelsGroup = computed(() => groupLabelled(props.field.type))
|
||||
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
|
||||
|
||||
const describedBy = computed(() =>
|
||||
@@ -36,7 +39,10 @@ const describedBy = computed(() =>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
|
||||
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
|
||||
<span v-if="labelsGroup" :id="`${controlId}-label`" class="font-semibold">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</span>
|
||||
<label v-else-if="!selfLabelled" :for="controlId" class="font-semibold">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
|
||||
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>
|
||||
28
admin/src/components/form/formContext.ts
Normal file
28
admin/src/components/form/formContext.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// What a record form shares with extension controls (D-05, D-08). FormView
|
||||
// provides these; a widget reads the current values and the locale, and
|
||||
// writes action results back through patch, which marks the form dirty and
|
||||
// clears that field's errors exactly like typing into it. A form without a
|
||||
// provider (settings pages) gets the defaults of the injecting control.
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { AdminRecord } from '../../api/types'
|
||||
|
||||
/** Read-only view of the form's current values. */
|
||||
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
|
||||
|
||||
/** Sets one field's value as if the admin had edited it. Nothing is saved. */
|
||||
export const FORM_PATCH: InjectionKey<(name: string, value: unknown) => void> = Symbol('summer.form.patch')
|
||||
|
||||
/** The form schema's locale (meta.locale). */
|
||||
export const FORM_LOCALE: InjectionKey<Readonly<Ref<string>>> = Symbol('summer.form.locale')
|
||||
|
||||
/**
|
||||
* The form controller's plugin assets: resolves once its scripts have loaded
|
||||
* or failed, with the failed URLs (pluginAssets.loadControllerAssets).
|
||||
*/
|
||||
export const FORM_ASSETS: InjectionKey<() => Promise<string[]>> = Symbol('summer.form.assets')
|
||||
|
||||
/** Event a widget element dispatches to run its action (bubbles, composed). */
|
||||
export const WIDGET_EVENT = 'summer-action'
|
||||
|
||||
/** How long a widget waits for its custom element to be defined. */
|
||||
export const WIDGET_TIMEOUT = 5000
|
||||
@@ -3,7 +3,9 @@
|
||||
// dashed box, so an unknown type never breaks the form. The relation manager
|
||||
// is registered like any control but holds no form value: it edits its
|
||||
// relation through its own endpoints and renders only on an existing record.
|
||||
// Phase 10.1 turns this seam into the plugin extension point.
|
||||
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
|
||||
// element (D-04, D-09). It holds no form value either, but it renders on
|
||||
// create and update.
|
||||
import type { Component } from 'vue'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
import DropdownField from './fields/DropdownField.vue'
|
||||
@@ -14,6 +16,7 @@ import SwitchField from './fields/SwitchField.vue'
|
||||
import TextField from './fields/TextField.vue'
|
||||
import TextareaField from './fields/TextareaField.vue'
|
||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||
import WidgetField from './fields/WidgetField.vue'
|
||||
|
||||
// The control helpers live in ./control so the field components never import
|
||||
// this module: registry -> field -> registry would be an import cycle whose
|
||||
@@ -33,24 +36,34 @@ const renderers = new Map<string, Component>([
|
||||
['checkbox', CheckboxField],
|
||||
['relation', RelationField],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
['widget', WidgetField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
|
||||
|
||||
/**
|
||||
* Types that need a saved record and hold no form value: never rendered on
|
||||
* create, never part of the save body (D-05, design screen 5).
|
||||
* Types that need a saved record: never rendered on create (D-05, design
|
||||
* screen 5).
|
||||
*/
|
||||
const recordBound = new Set<string>([RELATION_MANAGER])
|
||||
|
||||
/** Types that hold no form value: never part of the save body (D-09). */
|
||||
const valueless = new Set<string>([RELATION_MANAGER, 'widget'])
|
||||
|
||||
/**
|
||||
* Types whose control is a group rather than one focusable element: the
|
||||
* visible label is a span the group points at, not a label for an input.
|
||||
*/
|
||||
const groupLabelledTypes = new Set<string>(['widget'])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? UnsupportedField
|
||||
}
|
||||
|
||||
/** Whether the SPA can edit values of this field type. */
|
||||
export function isRegistered(type: string): boolean {
|
||||
return renderers.has(type) && !recordBound.has(type)
|
||||
return renderers.has(type) && !valueless.has(type)
|
||||
}
|
||||
|
||||
/** Whether a field type renders only on an existing record (relation manager). */
|
||||
@@ -61,3 +74,8 @@ export function needsRecord(type: string): boolean {
|
||||
export function ownsLabel(type: string): boolean {
|
||||
return selfLabelled.has(type)
|
||||
}
|
||||
|
||||
/** Whether a field type's label labels a group (widget) instead of a control. */
|
||||
export function groupLabelled(type: string): boolean {
|
||||
return groupLabelledTypes.has(type)
|
||||
}
|
||||
|
||||
22
admin/src/components/ui/ExtensionFailure.vue
Normal file
22
admin/src/components/ui/ExtensionFailure.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
|
||||
// The extension failure box (UI-SPEC S5): UnsupportedField's geometry with a
|
||||
// danger icon, shown when a widget or partial could not be loaded. One line
|
||||
// sits centred in the 44px box; longer text wraps, the box grows with 10px
|
||||
// vertical padding and the icon stays on the first line.
|
||||
defineProps<{ text: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="alert"
|
||||
data-extension-failure
|
||||
class="flex min-h-input items-center rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 py-2.5 text-[13px] text-muted"
|
||||
>
|
||||
<span class="flex min-w-0 items-start gap-2.5">
|
||||
<CircleAlert :size="16" class="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<span class="min-w-0 [overflow-wrap:anywhere]">{{ text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watchEffect } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { message, t } from '../app/i18n'
|
||||
import { currentLocale, message, t } from '../app/i18n'
|
||||
import { loadControllerAssets } from '../app/pluginAssets'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_VALUES } from '../components/form/formContext'
|
||||
import { needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
@@ -63,6 +65,8 @@ const confirm = useConfirm()
|
||||
// Set right before a navigation the form itself starts after a save or a
|
||||
// delete, so the dirty guard does not ask.
|
||||
let leaving = false
|
||||
// The controller's plugin scripts: settled when every one loaded or failed.
|
||||
let assetsReady: Promise<string[]> = Promise.resolve([])
|
||||
|
||||
// A relation manager needs a saved record: on create it is dropped with its
|
||||
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
|
||||
@@ -150,6 +154,10 @@ async function load(): Promise<void> {
|
||||
])
|
||||
schema.value = schemaResult.data?.data ?? null
|
||||
failed.value = !schemaResult.data || (recordResult !== null && !recordResult.data)
|
||||
if (schema.value) {
|
||||
// Plugin files load in the background; only widgets wait for them (D-14).
|
||||
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
|
||||
}
|
||||
if (schema.value && mode === 'create') {
|
||||
values.value = initialValues(fields.value)
|
||||
}
|
||||
@@ -167,6 +175,14 @@ function update(name: string, value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Extension controls (widgets) read the values and locale and write action
|
||||
// results back through update, so a fill marks the form dirty and clears
|
||||
// that field's errors like an edit (D-07, D-08).
|
||||
provide(FORM_VALUES, computed(() => values.value))
|
||||
provide(FORM_PATCH, update)
|
||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
||||
provide(FORM_ASSETS, () => assetsReady)
|
||||
|
||||
/** 422: messages under fields, the first invalid field (schema order) focused. */
|
||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(error?.details)
|
||||
|
||||
68
admin/tests/fixtures/extension.form-schema.json
vendored
Normal file
68
admin/tests/fixtures/extension.form-schema.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Widget",
|
||||
"fields": [
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Name",
|
||||
"type": "text",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"label": "Colour",
|
||||
"type": "text",
|
||||
"span": "right"
|
||||
},
|
||||
{
|
||||
"name": "lookup",
|
||||
"label": "Catalogue",
|
||||
"type": "widget",
|
||||
"comment": "Fills in the name. Save to keep it.",
|
||||
"widget": "acme-demo-lookup",
|
||||
"action": "lookup",
|
||||
"actionLabel": "Look up",
|
||||
"fill": ["name"]
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": {
|
||||
"other": "New widget"
|
||||
},
|
||||
"update": {
|
||||
"other": "Editing widget"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Widget „:name” saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this widget?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": ["/admin-test/assets/acme/demo/js/lookup.js?v=abc"],
|
||||
"styles": []
|
||||
},
|
||||
"redirects": {
|
||||
"create": {
|
||||
"redirect": "acme/demo/widgets/update/:id",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/demo/widgets",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"default": "acme/demo/widgets"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
12
admin/tests/fixtures/lang.json
vendored
12
admin/tests/fixtures/lang.json
vendored
@@ -21,6 +21,18 @@
|
||||
"backend::lang.auth.title": {
|
||||
"other": "Witaj ponownie"
|
||||
},
|
||||
"backend::lang.extension.action_failed": {
|
||||
"other": "Nie udało się wykonać akcji. Spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.extension.busy": {
|
||||
"other": "Wczytywanie…"
|
||||
},
|
||||
"backend::lang.extension.partial_failed": {
|
||||
"other": "Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie."
|
||||
},
|
||||
"backend::lang.extension.widget_failed": {
|
||||
"other": "Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki."
|
||||
},
|
||||
"backend::lang.form.add": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
@@ -27,6 +28,8 @@ export const settingsFixture: {
|
||||
values: S['cabana.Envelope-cabana_SettingsResult']
|
||||
} = settingsJson
|
||||
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
export const listFixture: Rows = listJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
export const optionsFixture: {
|
||||
|
||||
302
admin/tests/smoke/extension.smoke.test.ts
Normal file
302
admin/tests/smoke/extension.smoke.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
|
||||
// custom element bridged to its action. Fixtures are neutral acme.demo.*
|
||||
// data; no application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import {
|
||||
activateStyles,
|
||||
assetAllowed,
|
||||
loadControllerAssets,
|
||||
loadScript,
|
||||
loadStyles,
|
||||
OWNER_ATTRIBUTE,
|
||||
} from '../../src/app/pluginAssets'
|
||||
import {
|
||||
FORM_ASSETS,
|
||||
FORM_LOCALE,
|
||||
FORM_PATCH,
|
||||
FORM_VALUES,
|
||||
WIDGET_EVENT,
|
||||
WIDGET_TIMEOUT,
|
||||
} from '../../src/components/form/formContext'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
|
||||
import { isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
|
||||
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
const WIDGET_ACTION = `${BASE}/widgets/lookup`
|
||||
const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc'
|
||||
|
||||
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
|
||||
|
||||
// The plugin's element: plain JS, no Vue, defined before the form mounts as
|
||||
// the plugin script would.
|
||||
class AcmeDemoLookup extends HTMLElement {}
|
||||
if (!customElements.get('acme-demo-lookup')) {
|
||||
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
||||
}
|
||||
|
||||
interface Seen {
|
||||
scripts: string[]
|
||||
links: HTMLLinkElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin files never load in happy-dom: a script "loads" on the next
|
||||
* microtask (or errors when listed in failing) and links are only recorded.
|
||||
*/
|
||||
function stubAssets(failing: string[] = []): Seen {
|
||||
const seen: Seen = { scripts: [], links: [] }
|
||||
const head = document.head
|
||||
const append = head.appendChild.bind(head)
|
||||
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) {
|
||||
const src = node.getAttribute('src') ?? ''
|
||||
seen.scripts.push(src)
|
||||
queueMicrotask(() => node.dispatchEvent(new Event(failing.includes(src) ? 'error' : 'load')))
|
||||
return node
|
||||
}
|
||||
if (node instanceof HTMLLinkElement) {
|
||||
seen.links.push(node)
|
||||
return node
|
||||
}
|
||||
return append(node)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
[`PUT ${RECORD}`]: async (request) => ({
|
||||
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A route whose reply the test releases, to observe the in-flight state. */
|
||||
function deferred(): { route: Route; release: (reply: Reply) => void } {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { route: () => pending, release }
|
||||
}
|
||||
|
||||
function widget(wrapper: VueWrapper): HTMLElement {
|
||||
const element = wrapper.find('acme-demo-lookup').element
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('widget element not mounted')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function act(element: HTMLElement): void {
|
||||
element.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('plugin assets (D-14, D-16)', () => {
|
||||
it('accepts only same-origin files under {base}/assets/', () => {
|
||||
expect(assetAllowed(SCRIPT)).toBe(true)
|
||||
for (const url of [
|
||||
'https://cdn.example.test/admin-test/assets/x.js',
|
||||
'//cdn.example.test/admin-test/assets/x.js',
|
||||
'/admin-test/api/v1/x.js',
|
||||
'/admin-test/assets/../api/v1/x.js',
|
||||
'/admin-test/assets/a\\b.js',
|
||||
'/admin-test/assets/a b.js',
|
||||
'/other/assets/x.js',
|
||||
]) {
|
||||
expect(assetAllowed(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a script once per URL, refuses foreign URLs and retries after a failure', async () => {
|
||||
const failing = '/admin-test/assets/acme/demo/js/broken.js?v=1'
|
||||
const seen = stubAssets([failing])
|
||||
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow()
|
||||
const url = '/admin-test/assets/acme/demo/js/once.js?v=1'
|
||||
const first = loadScript(url)
|
||||
expect(loadScript(url)).toBe(first)
|
||||
await first
|
||||
await expect(loadScript(failing)).rejects.toThrow()
|
||||
await expect(loadScript(failing)).rejects.toThrow()
|
||||
expect(seen.scripts).toEqual([url, failing, failing])
|
||||
|
||||
expect(await loadControllerAssets('acme.demo.widgets', { scripts: [url, failing], styles: [] })).toEqual([failing])
|
||||
})
|
||||
|
||||
it('owns stylesheet links per controller and disables the other controllers', () => {
|
||||
const seen = stubAssets()
|
||||
const css = '/admin-test/assets/acme/demo/css/demo.css?v=1'
|
||||
loadStyles('acme.demo.one', [css, 'https://cdn.example.test/x.css'])
|
||||
loadStyles('acme.demo.one', [css])
|
||||
loadStyles('acme.demo.two', [css])
|
||||
expect(seen.links).toHaveLength(2)
|
||||
const [one, two] = seen.links
|
||||
expect(one!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.one')
|
||||
expect(one!.rel).toBe('stylesheet')
|
||||
activateStyles('acme.demo.two')
|
||||
expect(one!.disabled).toBe(true)
|
||||
expect(two!.disabled).toBe(false)
|
||||
activateStyles('acme.demo.one')
|
||||
expect(one!.disabled).toBe(false)
|
||||
expect(two!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget field (D-04, D-05, D-07, D-08)', () => {
|
||||
it('is a registered, valueless control that renders on create', () => {
|
||||
expect(rendererFor('widget')).toBe(WidgetField)
|
||||
expect(isRegistered('widget')).toBe(false)
|
||||
expect(needsRecord('widget')).toBe(false)
|
||||
expect(FORM_VALUES).not.toBe(FORM_PATCH)
|
||||
expect(FORM_LOCALE).not.toBe(FORM_ASSETS)
|
||||
expect(WIDGET_TIMEOUT).toBe(5000)
|
||||
})
|
||||
|
||||
it('mounts the element with attributes only and keeps fill-values current', async () => {
|
||||
const seen = stubAssets()
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
await flushPromises()
|
||||
|
||||
expect(seen.scripts).toContain(SCRIPT)
|
||||
const host = wrapper.find('[data-widget-host]')
|
||||
expect(host.attributes('role')).toBe('group')
|
||||
expect(host.attributes('id')).toBe('field-lookup')
|
||||
expect(host.attributes('aria-labelledby')).toBe('field-lookup-label')
|
||||
expect(host.attributes('aria-describedby')).toBe('field-lookup-comment')
|
||||
expect(host.attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper.find('#field-lookup-label').text()).toBe('Catalogue')
|
||||
expect(wrapper.find('label[for="field-lookup"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
|
||||
|
||||
const element = widget(wrapper)
|
||||
expect(element.getAttribute('record-id')).toBe('1')
|
||||
expect(element.getAttribute('field-name')).toBe('lookup')
|
||||
expect(element.getAttribute('locale')).toBe('en')
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
||||
expect(element.getAttribute('label')).toBe('Look up')
|
||||
expect(element.getAttribute('busy-label')).toBe('Wczytywanie…')
|
||||
expect(element.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
|
||||
expect(Object.keys(element)).toEqual([])
|
||||
|
||||
await wrapper.find('#field-name').setValue('Red widget')
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Red widget"}')
|
||||
})
|
||||
|
||||
it('posts the action once while busy and patches only the fill keys the server returned', async () => {
|
||||
stubAssets()
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
|
||||
act(element)
|
||||
await flushPromises()
|
||||
expect(element.hasAttribute('busy')).toBe(true)
|
||||
act(element)
|
||||
await flushPromises()
|
||||
const posts = requestsTo(calls, 'POST', WIDGET_ACTION)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await posts[0]!.json()).toEqual({ record_id: 1, values: { name: 'Blue widget' } })
|
||||
|
||||
action.release({ body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', color: '#ff0000' } }, meta: {} } })
|
||||
await flushPromises()
|
||||
|
||||
expect(element.hasAttribute('busy')).toBe(false)
|
||||
expect(element.hasAttribute('state')).toBe(false)
|
||||
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
|
||||
expect((wrapper.find('#field-color').element as HTMLInputElement).value).toBe('#0000ff')
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Name filled in')
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const [put] = requestsTo(calls, 'PUT', RECORD)
|
||||
expect(await put!.json()).toEqual({ name: 'Catalogue widget', color: '#0000ff' })
|
||||
})
|
||||
|
||||
it('toasts the server message, marks the element and leaves the values on failure', async () => {
|
||||
stubAssets()
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({
|
||||
[`POST ${WIDGET_ACTION}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
act(element)
|
||||
await flushPromises()
|
||||
|
||||
expect(element.getAttribute('state')).toBe('error')
|
||||
expect(element.hasAttribute('busy')).toBe(false)
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Catalogue unreachable.')
|
||||
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Blue widget')
|
||||
})
|
||||
|
||||
it('falls back to the framework message when the error has none', async () => {
|
||||
stubAssets()
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({ [`POST ${WIDGET_ACTION}`]: { status: 500, body: {} } }),
|
||||
)
|
||||
await flushPromises()
|
||||
act(widget(wrapper))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
|
||||
})
|
||||
|
||||
it('renders on create with an empty record id and empty fill values', async () => {
|
||||
stubAssets()
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
expect(element.getAttribute('record-id')).toBe('')
|
||||
expect(element.getAttribute('fill-values')).toBe('{}')
|
||||
|
||||
act(element)
|
||||
await flushPromises()
|
||||
expect(await requestsTo(calls, 'POST', WIDGET_ACTION)[0]!.json()).toEqual({ values: {} })
|
||||
action.release({ body: { data: { message: 'Done', fill: {} }, meta: {} } })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('shows the failure box instead of the element when its script fails', async () => {
|
||||
const schema = clone(extensionFormSchemaFixture)
|
||||
const broken = '/admin-test/assets/acme/demo/js/missing.js?v=1'
|
||||
schema.data.assets.scripts = [broken]
|
||||
schema.data.fields = schema.data.fields.map((field) =>
|
||||
field.name === 'lookup' ? { ...field, widget: 'acme-demo-missing' } : field,
|
||||
)
|
||||
stubAssets([broken])
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
|
||||
await flushPromises()
|
||||
|
||||
const box = wrapper.find('[data-widget-failed]')
|
||||
expect(box.attributes('role')).toBe('alert')
|
||||
expect(box.attributes('id')).toBe('field-lookup')
|
||||
expect(box.text()).toBe('Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.')
|
||||
expect(wrapper.find('acme-demo-missing').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user