From 83fa2f7b2c9217aff7973080a46aed72821b7092 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 11:10:41 +0200 Subject: [PATCH] feat(16.2-01): host plugin field type elements in the admin SPA - loadFieldTypes fetches GET /field-types after sign-in, loads each script once and adds the styles as global links activateStyles never disables - PluginField creates the type's element, sets the value property and the contract attributes, takes summer-change as the form value and never writes back a value the element just emitted - rendererForField and isRegisteredField route any field with an element to PluginField in forms, previews and the save payload - rebuilt modules/boardwalk/dist --- admin/src/api/types.ts | 2 + admin/src/app/fieldTypes.ts | 52 ++++++ admin/src/app/pluginAssets.ts | 25 ++- admin/src/components/form/FieldRenderer.vue | 7 +- admin/src/components/form/FormField.vue | 9 +- admin/src/components/form/PreviewField.vue | 11 +- .../components/form/fields/PluginField.vue | 175 ++++++++++++++++++ admin/src/components/form/formContext.ts | 6 + admin/src/components/form/formState.ts | 4 +- admin/src/components/form/registry.ts | 24 +++ admin/src/main.ts | 8 +- admin/src/views/LoginView.vue | 3 +- admin/tests/form/PluginField.test.ts | 155 ++++++++++++++++ .../boardwalk/dist/assets/index-By4vm3Ku.js | 9 + .../boardwalk/dist/assets/index-CNLOPBsM.js | 9 - modules/boardwalk/dist/index.html | 2 +- 16 files changed, 471 insertions(+), 30 deletions(-) create mode 100644 admin/src/app/fieldTypes.ts create mode 100644 admin/src/components/form/fields/PluginField.vue create mode 100644 admin/tests/form/PluginField.test.ts create mode 100644 modules/boardwalk/dist/assets/index-By4vm3Ku.js delete mode 100644 modules/boardwalk/dist/assets/index-CNLOPBsM.js diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index aa0678c..3fd551d 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -54,6 +54,8 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest'] export type AdminActionResult = Schemas['cabana.AdminActionResult'] /** A controller's plugin script and stylesheet URLs under {base}/assets/. */ export type ControllerAssets = Schemas['cabana.ControllerAssets'] +/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */ +export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets'] /** A registered toolbar action the admin may run, with its localized label. */ export type ToolbarAction = Schemas['cabana.ToolbarAction'] /** One node of a rendered partial: an allowlisted element or a text node. */ diff --git a/admin/src/app/fieldTypes.ts b/admin/src/app/fieldTypes.ts new file mode 100644 index 0000000..a037bde --- /dev/null +++ b/admin/src/app/fieldTypes.ts @@ -0,0 +1,52 @@ +// Plugin field types (pact.HasFormFieldTypes, D-13). After sign-in the SPA +// asks GET {api}/field-types once and loads every type's scripts as module +// scripts (each URL once, through loadScript) and its stylesheets as global +// links that no navigation disables, so a type's element works in any +// controller's form. Nothing here ever rejects: a script that fails to load +// surfaces later as that field's failure state. +import { api } from '../api/client' +import type { FieldTypeAssets } from '../api/types' +import { loadGlobalStyles, loadScript } from './pluginAssets' + +// Element tag -> the script URLs of its type that failed to load. +const scriptsByElement = new Map>() + +/** + * Fetches the registered field types, starts loading their scripts and + * enables their styles. Resolves with the list (empty on any failure) + * without waiting for the scripts. + */ +export async function loadFieldTypes(): Promise { + let types: FieldTypeAssets[] = [] + try { + const { data, response } = await api.GET('/field-types') + types = response.ok && data ? data.data : [] + } catch { + return [] + } + for (const type of types) { + loadGlobalStyles(type.styles ?? []) + const urls = type.scripts ?? [] + scriptsByElement.set( + type.element, + Promise.allSettled(urls.map((url) => loadScript(url))).then((results) => + urls.filter((_, index) => results[index]?.status === 'rejected'), + ), + ) + } + return types +} + +/** + * Resolves once the scripts of the field type that defines `element` have + * loaded or failed, with the URLs that failed; at once with none when the + * element belongs to no loaded field type. + */ +export function fieldTypeScripts(element: string): Promise { + return scriptsByElement.get(element) ?? Promise.resolve([]) +} + +/** Forgets the loaded field types (tests and a new session). */ +export function resetFieldTypes(): void { + scriptsByElement.clear() +} diff --git a/admin/src/app/pluginAssets.ts b/admin/src/app/pluginAssets.ts index fdf277c..c719ef1 100644 --- a/admin/src/app/pluginAssets.ts +++ b/admin/src/app/pluginAssets.ts @@ -7,7 +7,10 @@ // every navigation to another controller or to a screen that is not a // controller view, so plugin CSS never styles a view it was not written for. // Only the router picks the enabled controller; a schema response that -// arrives after its view was left adds its links disabled. +// arrives after its view was left adds its links disabled. Stylesheets of +// plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's +// element can appear in any controller's form, so its links are never +// disabled on navigation. // Files load only through script and link elements; nothing here fetches. import type { ControllerAssets } from '../api/types' import { runtime } from './runtime' @@ -15,6 +18,9 @@ import { runtime } from './runtime' /** Attribute naming the controller that owns a plugin stylesheet link. */ export const OWNER_ATTRIBUTE = 'data-summer-controller' +/** Owner of stylesheet links that stay enabled on every screen (field types). */ +export const GLOBAL_STYLE_OWNER = '*' + 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. @@ -90,7 +96,7 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void const link = document.createElement('link') link.rel = 'stylesheet' link.setAttribute(OWNER_ATTRIBUTE, controllerId) - link.disabled = controllerId !== active + link.disabled = !styleEnabled(controllerId) link.href = url styles.set(key, { owner: controllerId, link }) document.head.appendChild(link) @@ -105,10 +111,23 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void export function activateStyles(controllerId: string): void { active = controllerId for (const { owner, link } of styles.values()) { - link.disabled = owner !== active + link.disabled = !styleEnabled(owner) } } +/** Whether links of this owner are enabled now: the active controller's and the global ones. */ +function styleEnabled(owner: string): boolean { + return owner === GLOBAL_STYLE_OWNER || owner === active +} + +/** + * Adds stylesheet links that stay enabled on every screen: the styles of + * plugin field types, whose elements may render in any controller's form. + */ +export function loadGlobalStyles(urls: readonly string[]): void { + loadStyles(GLOBAL_STYLE_OWNER, urls) +} + /** * Adds the controller's stylesheet links and loads its scripts. The links * are enabled only while the router shows that controller, so a response diff --git a/admin/src/components/form/FieldRenderer.vue b/admin/src/components/form/FieldRenderer.vue index dd380cc..46b5154 100644 --- a/admin/src/components/form/FieldRenderer.vue +++ b/admin/src/components/form/FieldRenderer.vue @@ -1,13 +1,14 @@