From 107d820109bf6feddb4dc6065cfe377fa01b75e3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 29 Sep 2026 02:04:34 +0200 Subject: [PATCH] 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 --- admin/src/api/types.ts | 6 + admin/src/app/pluginAssets.ts | 110 +++++++ admin/src/components/form/FormField.vue | 10 +- .../components/form/fields/WidgetField.vue | 173 ++++++++++ admin/src/components/form/formContext.ts | 28 ++ admin/src/components/form/registry.ts | 26 +- admin/src/components/ui/ExtensionFailure.vue | 22 ++ admin/src/views/FormView.vue | 20 +- .../tests/fixtures/extension.form-schema.json | 68 ++++ admin/tests/fixtures/lang.json | 12 + admin/tests/fixtures/typed.ts | 3 + admin/tests/smoke/extension.smoke.test.ts | 302 ++++++++++++++++++ .../boardwalk/dist/assets/index-BAlwlQ8W.js | 3 - .../boardwalk/dist/assets/index-CLf0gZ3D.css | 1 - .../boardwalk/dist/assets/index-CnkxaoJL.js | 4 + .../boardwalk/dist/assets/index-DQRkkGSM.css | 1 + modules/boardwalk/dist/index.html | 4 +- modules/phrasebook/backend/lang/en/lang.yaml | 5 + modules/phrasebook/backend/lang/pl/lang.yaml | 5 + 19 files changed, 789 insertions(+), 14 deletions(-) create mode 100644 admin/src/app/pluginAssets.ts create mode 100644 admin/src/components/form/fields/WidgetField.vue create mode 100644 admin/src/components/form/formContext.ts create mode 100644 admin/src/components/ui/ExtensionFailure.vue create mode 100644 admin/tests/fixtures/extension.form-schema.json create mode 100644 admin/tests/smoke/extension.smoke.test.ts delete mode 100644 modules/boardwalk/dist/assets/index-BAlwlQ8W.js delete mode 100644 modules/boardwalk/dist/assets/index-CLf0gZ3D.css create mode 100644 modules/boardwalk/dist/assets/index-CnkxaoJL.js create mode 100644 modules/boardwalk/dist/assets/index-DQRkkGSM.css diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 454d7aa..1907a4f 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -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). */ diff --git a/admin/src/app/pluginAssets.ts b/admin/src/app/pluginAssets.ts new file mode 100644 index 0000000..76ca15f --- /dev/null +++ b/admin/src/app/pluginAssets.ts @@ -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>() +// 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() + +/** 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 { + 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((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 { + 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') +} diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue index b9ad7f0..2011fbc 100644 --- a/admin/src/components/form/FormField.vue +++ b/admin/src/components/form/FormField.vue @@ -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(() =>