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
This commit is contained in:
2026-10-10 11:10:41 +02:00
parent 717d46ec3d
commit 83fa2f7b2c
16 changed files with 471 additions and 30 deletions

View File

@@ -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<string, Promise<string[]>>()
/**
* 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<FieldTypeAssets[]> {
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<string[]> {
return scriptsByElement.get(element) ?? Promise.resolve([])
}
/** Forgets the loaded field types (tests and a new session). */
export function resetFieldTypes(): void {
scriptsByElement.clear()
}

View File

@@ -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<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.
@@ -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