diff --git a/studio-ui/docs/type-builder-forms-engine-plugins.md b/studio-ui/docs/type-builder-forms-engine-plugins.md index ba07be17..6219bdb4 100644 --- a/studio-ui/docs/type-builder-forms-engine-plugins.md +++ b/studio-ui/docs/type-builder-forms-engine-plugins.md @@ -2,7 +2,7 @@ > Companion to [`type-builder-forms-engine.md`](type-builder-forms-engine.md). Read this before designing or changing dynamic controls, data sources, FE form controllers, plugin loading, or TB plugin discovery. -Last updated: 2026-08-03 +Last updated: 2026-08-07 ## 1. Why plugins are part of the core design @@ -282,21 +282,28 @@ export default { controls: { 'my-custom-picker': { Component: MyPicker, - dataSourceBindings: [{ propertyName: 'itemManager', interfaces: ['item'], selection: 'multi' }] + dataSourceBindings: [{ propertyName: 'itemManager', interfaces: ['item'], selection: 'multi' }], + valueRetriever: (raw, field) => /* XML → form value */, + valueSerializer: (field, value) => /* form value → XML-ready shape */, + validator: (field, value, messages, meta) => /* type-specific; push messages; return bool */ } } }; ``` -Eager alternative for UMD loaders: `craftercms.formsEngine.controls.registerDataSourceBindings(type, bindings)` (bindings only; prefer `descriptor.controls` for the component). +Lookup order for IO and validators: built-in `valueRetrieverLookup` / `valueSerializersLookup` / `validatorsMap` first (`Object.hasOwn`), then plugin contribution for `field.type`. Required/empty checks stay in `validateFieldValue` (host). FE preloads all `field.properties.plugin` locators before form value parse and again before save (`preloadControlPluginsForFields`) so custom retrievers/serializers/validators are registered in time. -Example: `samples/fe2-control-plugin.example.mjs`. +Eager alternative for UMD loaders: `craftercms.formsEngine.controls.registerDataSourceBindings(type, bindings)` (bindings only; prefer `descriptor.controls` for the component). Host also exposes `getValueRetriever` / `getValueSerializer` / `getValidator` for inspection. + +**Field chrome.** Plugin controls are rendered without a wrapper, so validation state is invisible unless the control renders it. `craftercms.formsEngine.controls.FormsEngineField` is the same component built-in controls use — it reads the field's validity atom and renders the label, required/invalid indicator, validator messages, field menu, and inheritance notice: + +```js +const { FormsEngineField } = globalThis.craftercms?.formsEngine?.controls ?? {}; +// {yourInput} +``` Missing for a complete extension contract: -- plugin-supplied value retriever; -- serializer; -- validators; - additional XML fields/attributes; - migrations/versioning; - optional descriptor for TB property editing (should mirror `dataSourceBindings` for the DS property UI); @@ -459,13 +466,16 @@ interface PluginDescriptor { interface ControlPluginContribution { Component: ComponentType; dataSourceBindings?: DataSourceBinding | readonly DataSourceBinding[]; + valueRetriever?: ValueRetriever; // optional XML → form value + valueSerializer?: ValueSerializer; // optional form value → XML shape + validator?: ValidatorFunctionDef; // optional type-specific; required/empty is host-owned } ``` `registerPlugin` (current): 1. Validates and installs `dataSources` into `dataSourceModuleRegistry` (before committing the plugin id). -2. Validates and installs `controls` into the control contribution registry + binding registry. +2. Validates and installs `controls` into the control contribution registry + binding registry (including optional `valueRetriever` / `valueSerializer` / `validator`). 3. Stores the descriptor (including `utils`) in the plugins map. 4. Registers widgets, locales, scripts/stylesheets as before. 5. `widgets` may be omitted (lib / FE-only bundles). diff --git a/studio-ui/docs/type-builder-forms-engine.md b/studio-ui/docs/type-builder-forms-engine.md index 985d95ae..da24a92e 100644 --- a/studio-ui/docs/type-builder-forms-engine.md +++ b/studio-ui/docs/type-builder-forms-engine.md @@ -302,9 +302,11 @@ Example: `samples/fe2-control-plugin.example.mjs`. The runtime control contract is `ControlProps` (`value`, `setValue`, `field`, `contentType`, `readonly`, `autoFocus`, optional `dataSources`). Host helpers: - `window.craftercms.formsEngine.dataSources` — DS module registry -- `window.craftercms.formsEngine.controls` — `getControl(type)`, `registerDataSourceBindings`, `getDataSourceBindings` +- `window.craftercms.formsEngine.controls` — `getControl(type)`, `registerDataSourceBindings`, `getDataSourceBindings`, `getValueRetriever`, `getValueSerializer`, `getValidator`, `FormsEngineField` -This path is functional for plugin metadata already present in a form definition. `customControlMap` exists as an override seam used by Type Builder descriptor forms. Validators, value retrievers, and serializers remain static maps/switches, so a truly novel control may still need more than a React component + bindings export. +Plugin controls are rendered bare by `ResolvedControlRenderer` (built-ins wrap themselves in `FormsEngineField`). A plugin that wants label, required/invalid styling, and validity messages must wrap its input in the host-provided `FormsEngineField`; otherwise a failing validator only surfaces in the Table of Contents and the save gate. + +This path is functional for plugin metadata already present in a form definition. `customControlMap` exists as an override seam used by Type Builder descriptor forms. Plugin controls may supply optional `valueRetriever` / `valueSerializer` / `validator` on `ControlPluginContribution` (built-in static maps still win when they define the type). Required/empty validation stays host-owned in `validateFieldValue`. Additional current FE control gaps: @@ -633,6 +635,8 @@ Separate **completed design decisions** (`[x]`) from **remaining implementation - [x] **Form-controller design** — type-local FE2 ESM (`FormController` hooks), fetch via form_controller API, not `PluginDescriptor`. See §5.9. Implementation still open (below). - [x] **Control-plugin ownership** — after `importPlugin()`, ownership is validated against loaded `PluginDescriptor.id` (not the form-definition locator `pluginId`). See `controlPluginLoader.ts`. - [x] **Atomic FE plugin registration** — `registerPlugin` preflights all DS + control contributions before any registry commit. +- [x] **Plugin control valueRetriever / valueSerializer** — optional on `ControlPluginContribution`; registered with the control; FE preloads plugin locators before form parse and save. Walkthrough: [`fe2-plugin-control-io-and-validators.md`](fe2-plugin-control-io-and-validators.md). +- [x] **Plugin control validator** — optional on `ControlPluginContribution`; looked up via `getFieldValidator` after built-in `validatorsMap`; ToC uses `hasFieldValidator`. Required/empty remains host-owned. Walkthrough: [`fe2-plugin-control-io-and-validators.md`](fe2-plugin-control-io-and-validators.md). ### Remaining implementation / validation @@ -660,6 +664,10 @@ Separate **completed design decisions** (`[x]`) from **remaining implementation Keep newest first. One short bullet per meaningful session. +- **2026-08-07** — Expose `FormsEngineField` on `craftercms.formsEngine.controls`. Plugin controls render bare (built-ins wrap themselves), so a failing plugin validator previously showed only in the ToC; wrapping in the host field chrome restores parity (label, invalid styling, validity messages). +- **2026-08-07** — Plugin control validators: optional `ControlPluginContribution.validator` installed by `registerPlugin`; `getFieldValidator` / `hasFieldValidator` fall back after built-in `validatorsMap`; host `getValidator`; sample rejects angle brackets. Same preload path as IO hooks. +- **2026-08-07** — Plugin control IO hooks: `ControlPluginContribution.valueRetriever` / `valueSerializer` installed by `registerPlugin`, looked up after built-in maps in `valueRetrievers` / `valueSerializers`. Form bootstrap + save preload plugin locators via `preloadControlPluginsForFields` so hooks exist before parse/serialize. Host: `craftercms.formsEngine.controls.getValueRetriever` / `getValueSerializer`. Sample updated. +- **2026-08-06** — Controls cleanup (`7418`): retired unused `link-input` / `link-textarea` / `linked-dropdown` from FE2 maps + TB descriptors. Closed the former “non-rendering control-map entries” open item: those three are removed; `disabled` / `internal-name` remain TB catalog ids that remap on insert via `systemFieldsTypesMap` to `checkbox` / `input` (locked field ids). Documented under completed design decisions. - **2026-08-03** — Convergence-gap audit reflected in §8: remaining work includes S3/WebDAV stubs, null control-map entries (`disabled` / `internal-name` / `link-input` / `link-textarea`), FE2 RTE plugin parity, and focused compatibility tests. Control-plugin `PluginDescriptor.id` ownership checks and atomic `registerPlugin` preflight were already implemented — recorded under completed design decisions, not left as open gaps. - **2026-08-03** — Refined §5.9: all `FormController` hooks may be async (host awaits); clarified on-disk path, form_controller API, and FE2 loader call site (`formControllerLoader` from form bootstrap — not `importPlugin`). - **2026-08-03** — Decided FE2 form-controller design (§5.9): keep type-local `form-controller.js` gated by `hasJsController`; load via authenticated form_controller API + ESM Blob import; export `FormController` hooks (`initialize`, `isFieldRelevant`, `onBeforeSave`) — **not** a `PluginDescriptor`. FE1 YUI controllers are incompatible (migrate by rewrite). TB must fix Client-side Controller to edit `form-controller.js` instead of Groovy. Implementation still TODO. diff --git a/studio-ui/ui/app/src/components/FormsEngine/FormsEngine.tsx b/studio-ui/ui/app/src/components/FormsEngine/FormsEngine.tsx index cd52de55..43b1e67f 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -126,6 +126,7 @@ import SectionAccordion from './components/SectionAccordion'; import useSaveForm from './lib/useSaveForm'; import { FormPrepError } from './components/FormPrepError'; import { createParsedValuesObject } from './lib/valueRetrievers'; +import { preloadControlPluginsForFields } from './lib/controlPluginLoader'; import { fromString } from '../../utils/xml'; import { displayWithPendingChangesConfirm } from '../../utils/ui'; import useActiveUser from '../../hooks/useActiveUser'; @@ -334,6 +335,7 @@ function FormBootstrap(props: FormsEngineProps) { useEffect(() => { // Guard statement: If content types are not loaded, we can't proceed. if (!contentTypesLoaded) return; + let disposed = false; // TODO: If props are changed, things can be left off... previous item locked, edits get lost, etc. Not sure how much support for prop changes we should implement. const isChildForm = stackIndex > 0; // In the form stack, the present form being opened would be in the last position [length-1], the parent form state would be on [length-2] if it is nested (e.g. Root => Component(L1) => Repeat(L2)|Component(L2)). Otherwise,the parent should be the root. @@ -494,43 +496,54 @@ function FormBootstrap(props: FormsEngineProps) { fileName: atom('') }); const contentObject = createObjectWithSystemProps(contentType); - const values = createParsedValuesObject( - contentType.fields, - contentObject, - contentTypesById, - (fieldId, value, isAdditional) => { - setFieldAtoms( - stableFormContextRef, - contentType, - contentType.fields, - fieldId, - atoms, - value, - { siteId, contentTypesById }, - isAdditional - ); - }, - customControls - ); - const { [XmlKeys.fileName]: _, ...valuesWithoutFileName } = values; + const initCreateForm = () => { + if (disposed) return; + const values = createParsedValuesObject( + contentType.fields, + contentObject, + contentTypesById, + (fieldId, value, isAdditional) => { + setFieldAtoms( + stableFormContextRef, + contentType, + contentType.fields, + fieldId, + atoms, + value, + { siteId, contentTypesById }, + isAdditional + ); + }, + customControls + ); + const { [XmlKeys.fileName]: _, ...valuesWithoutFileName } = values; - const objectId = contentObject[XmlKeys.modelId] as string; - initializeState(atoms, values, { - id: objectId, - // TODO: Should/could we somehow deduce the target path? - path: null, - // TODO: Sourcemap? How can we determine what would be inherited by this content? New API? - sourceMap: null, - pathInSite: processPathMacros({ - path: create.path, - objectId, - fullParentPath: '', - useUUID: false - }), - contentType, - contentObject, - contentXml: buildContentXml(valuesWithoutFileName, contentTypesById) - }); + const objectId = contentObject[XmlKeys.modelId] as string; + initializeState(atoms, values, { + id: objectId, + // TODO: Should/could we somehow deduce the target path? + path: null, + // TODO: Sourcemap? How can we determine what would be inherited by this content? New API? + sourceMap: null, + pathInSite: processPathMacros({ + path: create.path, + objectId, + fullParentPath: '', + useUUID: false + }), + contentType, + contentObject, + contentXml: buildContentXml(valuesWithoutFileName, contentTypesById) + }); + }; + preloadControlPluginsForFields(siteId, contentType.fields) + .catch((error) => { + console.error('Failed to preload control plugins before create-form value parse.', error); + }) + .then(initCreateForm); + return () => { + disposed = true; + }; } /* if (isUpdateMode) */ else { const subscription = fetchUpdateRequirements({ siteId, @@ -570,37 +583,47 @@ function FormBootstrap(props: FormsEngineProps) { expandedStateBySectionId: buildSectionExpandedStateAtoms(requirements.contentType.sections), fileName: createFileNameAtom(requirements.item.path) }); - const values = createParsedValuesObject( - requirements.contentType.fields, - requirements.contentObject, - effectRefs.current.contentTypesById, - (fieldId, value, isAdditional) => { - setFieldAtoms( - stableFormContextRef, - requirements.contentType, + preloadControlPluginsForFields(siteId, requirements.contentType.fields) + .catch((error) => { + console.error('Failed to preload control plugins before edit-form value parse.', error); + }) + .then(() => { + if (disposed) return; + const values = createParsedValuesObject( requirements.contentType.fields, - fieldId, - atoms, - value, - { siteId, contentTypesById }, - isAdditional + requirements.contentObject, + effectRefs.current.contentTypesById, + (fieldId, value, isAdditional) => { + setFieldAtoms( + stableFormContextRef, + requirements.contentType, + requirements.contentType.fields, + fieldId, + atoms, + value, + { siteId, contentTypesById: effectRefs.current.contentTypesById }, + isAdditional + ); + }, + customControls ); - }, - customControls - ); - initializeState(atoms, values, { - id: values[XmlKeys.modelId] as string, - path: requirements.item.path, - // TODO: Sourcemap? How can we determine what would be inherited by this content? New API? - sourceMap: requirements.sourceMap, - pathInSite: requirements.pathInSite, - contentType: requirements.contentType, - contentXml: requirements.contentXml, - contentObject: requirements.contentObject - }); + initializeState(atoms, values, { + id: values[XmlKeys.modelId] as string, + path: requirements.item.path, + // TODO: Sourcemap? How can we determine what would be inherited by this content? New API? + sourceMap: requirements.sourceMap, + pathInSite: requirements.pathInSite, + contentType: requirements.contentType, + contentXml: requirements.contentXml, + contentObject: requirements.contentObject + }); + }); }); - return () => subscription.unsubscribe(); + return () => { + disposed = true; + subscription.unsubscribe(); + }; } }, [ contentTypesLoaded, @@ -1231,7 +1254,6 @@ export default FormGuard; // - Where do we put the "config" to determine whether to use new or old form engine? // - Form controller loading and execution // - FOR LATER... -// - Allow overriding/extending validators, retrievers, [and maybe] controlMap through plugins // - Inherited non overridable if not in the model // - AI // - Edit template & controller diff --git a/studio-ui/ui/app/src/components/FormsEngine/components/TableOfContents.tsx b/studio-ui/ui/app/src/components/FormsEngine/components/TableOfContents.tsx index d3f990c7..7593a377 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/components/TableOfContents.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/components/TableOfContents.tsx @@ -24,14 +24,13 @@ import SearchBar from '../../SearchBar'; import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; import Box from '@mui/material/Box'; import { useAtomValue, useSetAtom, useStore as useJotaiStore } from 'jotai/index'; -import { isEmptyValue, isFieldRequired, validatorsMap } from '../lib/validators'; +import { hasFieldValidator, isEmptyValue, isFieldRequired } from '../lib/validators'; import { atom } from 'jotai'; import { immutableEmptyArray } from '../../../utils/array'; import useLoadableAtom from '../lib/useLoadableAtom'; import Skeleton from '@mui/material/Skeleton'; import ErrorBoundary from '../../ErrorBoundary'; import FieldStateIndicator from './FieldStateIndicator'; -import { nnou } from '../../../utils/object'; export interface TableOfContentsProps { containerRef: RefObject; @@ -154,7 +153,7 @@ function TreeItemLabel({ const validityData = useLoadableAtom(atoms.validationByFieldId[field.id]); const isValid = validityData.state === 'hasData' ? validityData?.data.isValid : true; const isRequired = isFieldRequired(field); - const hasValidator = nnou(validatorsMap[field.type]); + const hasValidator = hasFieldValidator(field.type); return ( {field.name} diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/registry.ts b/studio-ui/ui/app/src/components/FormsEngine/controls/registry.ts index 764a2faf..43bd3704 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/controls/registry.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/controls/registry.ts @@ -16,24 +16,30 @@ import type { ComponentType } from 'react'; import type { DataSourceBinding } from '../dataSources/types'; +import type { ValueRetriever, ValueSerializer } from '../lib/controlValueTypes'; +import type { ValidatorFunctionDef } from '../lib/validators'; import type { ControlProps } from '../types'; -/** Cached plugin control: Component, normalized bindings, and owning `PluginDescriptor.id`. */ +/** Cached plugin control: Component, bindings, optional IO/validator hooks, and owning `PluginDescriptor.id`. */ export interface RegisteredControlContribution { Component: ComponentType; bindings: readonly DataSourceBinding[]; /** Owning {@link PluginDescriptor.id} — not the form-definition asset locator `pluginId`. */ pluginId: string; + valueRetriever?: ValueRetriever; + valueSerializer?: ValueSerializer; + validator?: ValidatorFunctionDef; } const registeredControls = new Map(); /** - * Stores an FE2 plugin control Component + bindings by `field.type`. + * Stores an FE2 plugin control contribution by `field.type`. * * Idempotent for the same owning plugin + Component. Throws if another plugin's descriptor already - * claims the type (even with an identical Component reference), so binding metadata cannot be - * overwritten under a retained first owner. + * claims the type (even with an identical Component reference), so binding/IO metadata cannot be + * overwritten under a retained first owner. Re-entry with the same owner+Component refreshes + * bindings and optional valueRetriever/valueSerializer/validator. */ export function registerControlContribution(controlType: string, contribution: RegisteredControlContribution): void { if (!controlType) { @@ -53,6 +59,13 @@ export function registerControlContribution(controlType: string, contribution: R ` by plugin "${existing.pluginId}".` ); } + registeredControls.set(controlType, { + ...existing, + bindings: contribution.bindings, + valueRetriever: contribution.valueRetriever ?? existing.valueRetriever, + valueSerializer: contribution.valueSerializer ?? existing.valueSerializer, + validator: contribution.validator ?? existing.validator + }); return; } registeredControls.set(controlType, contribution); @@ -66,3 +79,18 @@ export function getRegisteredControlContribution(controlType: string): Registere export function hasRegisteredControl(controlType: string): boolean { return registeredControls.has(controlType); } + +/** Plugin control valueRetriever for `field.type`, if contributed. */ +export function getPluginControlValueRetriever(controlType: string): ValueRetriever | undefined { + return registeredControls.get(controlType)?.valueRetriever; +} + +/** Plugin control valueSerializer for `field.type`, if contributed. */ +export function getPluginControlValueSerializer(controlType: string): ValueSerializer | undefined { + return registeredControls.get(controlType)?.valueSerializer; +} + +/** Plugin control validator for `field.type`, if contributed. */ +export function getPluginControlValidator(controlType: string): ValidatorFunctionDef | undefined { + return registeredControls.get(controlType)?.validator; +} diff --git a/studio-ui/ui/app/src/components/FormsEngine/dataSources/host.ts b/studio-ui/ui/app/src/components/FormsEngine/dataSources/host.ts index 3ce985c3..9a38ad09 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/dataSources/host.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/dataSources/host.ts @@ -15,9 +15,15 @@ */ import { getControlDataSourceBindings, registerControlDataSourceBindings } from './bindings'; -import { getRegisteredControlContribution } from '../controls/registry'; +import { + getPluginControlValidator, + getPluginControlValueRetriever, + getPluginControlValueSerializer, + getRegisteredControlContribution +} from '../controls/registry'; import { dataSourceModuleRegistry, registerDataSourceModule } from './registry'; import { DATA_SOURCE_API_VERSION } from './types'; +import { FormsEngineField } from '../components/FormsEngineField'; /** * Public runtime surface for the data-source *module registry* (built-ins and @@ -41,9 +47,17 @@ export const formsEngineDataSourcesHost = { * Prefer contributing controls via `PluginDescriptor.controls` (loaded through * `importPlugin` / `registerPlugin`). Eager binding registration remains available * for UMD-style loaders. + * + * `FormsEngineField` is the field chrome built-in controls use (label, required/invalid + * state, validity messages, field menu, inheritance notice). Plugin controls are rendered + * bare, so they must wrap their input in it to display validation state like built-ins do. */ export const formsEngineControlsHost = { registerDataSourceBindings: registerControlDataSourceBindings, getDataSourceBindings: getControlDataSourceBindings, - getControl: getRegisteredControlContribution + getControl: getRegisteredControlContribution, + getValueRetriever: getPluginControlValueRetriever, + getValueSerializer: getPluginControlValueSerializer, + getValidator: getPluginControlValidator, + FormsEngineField }; diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/controlPluginLoader.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/controlPluginLoader.ts index 14b6115a..5c2e3617 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/controlPluginLoader.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/controlPluginLoader.ts @@ -15,8 +15,9 @@ */ import { type ComponentType, use } from 'react'; -import type { FormDefinitionPlugin } from '../../../models/ContentType'; +import type { ContentTypeField, FormDefinitionPlugin } from '../../../models/ContentType'; import type PluginDescriptor from '../../../models/PluginDescriptor'; +import type LookupTable from '../../../models/LookupTable'; import { buildFileUrl, importPlugin } from '../../../services/plugin'; import { getRegisteredControlContribution } from '../controls/registry'; import type { DataSourceBinding } from '../dataSources/types'; @@ -39,6 +40,82 @@ function loadedCacheKey(url: string, controlType: string): string { return `${url}::${controlType}`; } +function toFieldList( + fields: LookupTable | ContentTypeField[] | undefined | null +): ContentTypeField[] { + if (!fields) return []; + return Array.isArray(fields) ? fields : Object.values(fields); +} + +/** + * Collects unique form-definition plugin locators from a field tree (including repeat nested fields). + * "Which plugin files does this form need?" + */ +export function collectControlPluginLocators( + fields: LookupTable | ContentTypeField[] | undefined | null +): FormDefinitionPlugin[] { + const out: FormDefinitionPlugin[] = []; + const seen = new Set(); + const walk = (list: ContentTypeField[]) => { + for (const field of list) { + const plugin = field.properties?.plugin as FormDefinitionPlugin | undefined; + if (plugin?.pluginId && plugin.type && plugin.name && plugin.filename) { + const key = `${plugin.pluginId}|${plugin.type}|${plugin.name}|${plugin.filename}`; + if (!seen.has(key)) { + seen.add(key); + out.push(plugin); + } + } + if (field.fields) { + walk(toFieldList(field.fields)); + } + } + }; + walk(toFieldList(fields)); + return out; +} + +/** + * Demand-loads every control plugin referenced by `fields` so `valueRetriever` / + * `valueSerializer` / `validator` contributions are registered before form bootstrap + * parse, validation, and save. Safe to call repeatedly; uses the same URL-keyed + * importPlugin cache as control rendering. + * “Load those plugins now, not when React first draws the control.” + * + */ +export function preloadControlPluginsForFields( + siteId: string, + fields: LookupTable | ContentTypeField[] | undefined | null +): Promise { + const locators = collectControlPluginLocators(fields); + if (!locators.length) return Promise.resolve(); + return Promise.all( + locators.map((plugin) => { + const builder = { + site: siteId, + type: plugin.type, + name: plugin.name, + file: plugin.filename, + id: plugin.pluginId + }; + const url = buildFileUrl(builder); + let loading = controlPluginCache.get(url); + if (!loading) { + loading = importPlugin(builder).catch((reason) => { + controlPluginCache.delete(url); + console.error( + `Failed to preload control plugin from \`${url}\` (needed for valueRetriever/valueSerializer/validator).`, + reason + ); + throw reason; + }); + controlPluginCache.set(url, loading); + } + return loading; + }) + ).then(() => undefined); +} + /** Builds the Studio plugin file URL for a form-definition plugin ref (same shape as DS plugin load). */ export function buildControlPluginUrl(siteId: string, plugin: FormDefinitionPlugin): string { return buildFileUrl(siteId, plugin.type, plugin.name, plugin.filename, plugin.pluginId); diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/controlValueTypes.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/controlValueTypes.ts new file mode 100644 index 00000000..a7ea4f46 --- /dev/null +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/controlValueTypes.ts @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2007-2026 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import type ContentType from '../../../models/ContentType'; +import type { ContentTypeField } from '../../../models/ContentType'; +import type LookupTable from '../../../models/LookupTable'; + +/** XML / form-value → in-memory control value (form load). */ +export type ValueRetriever = (value: unknown, field: ContentTypeField) => T; + +/** In-memory control value → XML-serialiser-ready shape (form save). */ +export type ValueSerializer = ( + field: ContentTypeField, + value: unknown, + contentTypesLookup?: LookupTable +) => T; diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/rteUtils.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/rteUtils.ts index 15b068e6..47017337 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/rteUtils.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/rteUtils.ts @@ -116,12 +116,14 @@ export function getTinyMceInitOptions( external_plugins: external, code_editor_inline: false, skin: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oxide-dark' : 'oxide', - // skin_url: '/studio/static-assets/libs/tinymce', content_css: (tinymceOptions?.content_css as string | string[])?.length ? tinymceOptions.content_css - : window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'default', + : // inline editors use the host page's own styles, so no need to load the default styles. + tinymceOptions.inline + ? [] + : window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'default', media_live_embeds: true, file_picker_types: 'image media', craftercms_paste_cleanup: tinymceOptions.craftercms_paste_cleanup ?? true, // If doesn't exist or if true => true diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/validators.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/validators.ts index ba3c33cf..7ced4025 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/validators.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/validators.ts @@ -33,14 +33,15 @@ import { getValidationValue } from './formUtils'; import type { NodeSelectorItem } from '../controls/NodeSelector'; import type { CheckboxGroupProps } from '../controls/CheckboxGroup'; import { macroCreatorLookupTable } from '../../ContentTypeManagement/controls/PathWithMacroCreator'; +import { getPluginControlValidator } from '../controls/registry'; -interface ValidatorMetaData { +export interface ValidatorMetaData { siteId: string; fileName: string; itemMeta: FormsEngineItemMetaContextProps; contentTypesById?: LookupTable; } -type ValidatorFunctionDef = ( +export type ValidatorFunctionDef = ( field: ContentTypeField, currentValue: unknown, messages: FieldValidityState['messages'], @@ -139,6 +140,23 @@ export async function fileNameValidator( } } +/** + * Built-in map first (including explicit `undefined` = no type validator). + * Plugin contributions only for types absent from the built-in map. + * Required/empty checks stay in {@link validateFieldValue}, not here. + */ +export function getFieldValidator(fieldType: string): ValidatorFunctionDef | undefined { + if (Object.hasOwn(validatorsMap, fieldType)) { + return validatorsMap[fieldType as BuiltInControlType | DescriptorControlType]; + } + return getPluginControlValidator(fieldType); +} + +/** True when a built-in or plugin type validator is registered for `fieldType`. */ +export function hasFieldValidator(fieldType: string): boolean { + return nnou(getFieldValidator(fieldType)); +} + /** * Validates the value of a field based on its type, requirements, and metadata. * @@ -164,7 +182,7 @@ export async function validateFieldValue( messages.push(defineMessage({ defaultMessage: 'This field is required.' })); return Promise.resolve({ isValid: false, messages }); } - const validator = validatorsMap[field.type as BuiltInControlType | DescriptorControlType]; + const validator = getFieldValidator(field.type); // If there's a validator, run it. If not, it's valid. const isValid = nnou(validator) ? await validator(field, validateValue, messages, meta) : true; return Promise.resolve({ isValid, messages }); diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts index 73ab90be..289a0d01 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts @@ -19,6 +19,7 @@ import ContentType, { ContentTypeField } from '../../../models/ContentType'; import type { BuiltInControlType } from './controlMap'; import type { RepeatItem } from '../controls/Repeat'; import type { NodeSelectorItem } from '../controls/NodeSelector'; +import { getPluginControlValueRetriever } from '../controls/registry'; import { systemFieldsNotInType, XmlKeys } from './formConsts'; import { deserialize, unescapeXml } from '../../../utils/xml'; import type { DescriptorControlType } from '../../ContentTypeManagement/controlMap'; @@ -27,8 +28,9 @@ import { nnou } from '../../../utils/object'; import { v4 as uuid } from 'uuid'; import { Matcher } from 'path-expression-matcher'; import { getAdditionalFieldsIdsFromDescriptor, resolveControlDescriptors } from './formUtils'; +import type { ValueRetriever } from './controlValueTypes'; -export type ValueRetriever = (value: unknown, field: ContentTypeField) => T; +export type { ValueRetriever } from './controlValueTypes'; export const valueRetrieverLookup: Record = { 'auto-filename': textFieldExtractor, @@ -186,7 +188,11 @@ export function createParsedValueForField( } export function retrieveFieldValue(field: ContentTypeField, value: unknown): T { - const retriever: ValueRetriever | undefined = valueRetrieverLookup[field.type]; + // Prefer the built-in map when the type is known there (including explicit `null` = no conversion). + // Only consult plugin contributions for types absent from the built-in map. + const retriever: ValueRetriever | null | undefined = Object.hasOwn(valueRetrieverLookup, field.type) + ? (valueRetrieverLookup[field.type] as ValueRetriever | null) + : (getPluginControlValueRetriever(field.type) as ValueRetriever | undefined); const defaultValue = field.defaultValue as string; // Value considering the defaultValue const fieldValue = value ?? (nnou(defaultValue) && defaultValue !== '' ? defaultValue : undefined); diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/valueSerializers.ts b/studio-ui/ui/app/src/components/FormsEngine/lib/valueSerializers.ts index 1dca86a9..1901eb14 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/valueSerializers.ts +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/valueSerializers.ts @@ -18,6 +18,7 @@ import { ContentTypeField } from '../../../models'; import { NodeSelectorItem } from '../controls/NodeSelector'; import LookupTable from '../../../models/LookupTable'; import ContentType from '../../../models/ContentType'; +import { getPluginControlValueSerializer } from '../controls/registry'; import { XmlKeys } from './formConsts'; import { BuiltInControlType } from './controlMap'; import { RepeatItem } from '../controls/Repeat'; @@ -26,16 +27,13 @@ import type { DescriptorControlType } from '../../ContentTypeManagement/controlM import { nnou } from '../../../utils/object'; import { escapeXml } from '../../../utils/xml'; import { AwsFile } from '../controls/AWSFileUpload'; +import type { ValueSerializer } from './controlValueTypes'; const attributeNamePrefix = '@:'; const cdataPropName = '__cdata__'; const textNodeName = '#text'; -export type ValueSerializer = ( - field: ContentTypeField, - value: unknown, - contentTypesLookup?: LookupTable -) => T; +export type { ValueSerializer } from './controlValueTypes'; export const valueSerializersLookup: Record = { 'auto-filename': undefined, @@ -119,7 +117,9 @@ function prepareValuesForXmlSerialising( const fieldAttributes: Record = {}; // Field type specific hinting... - const serializer = valueSerializersLookup[fieldType]; + const serializer = Object.hasOwn(valueSerializersLookup, fieldType) + ? valueSerializersLookup[fieldType] + : getPluginControlValueSerializer(fieldType); if (serializer) { jObj[id] = serializer(field, value, contentTypesLookup); } diff --git a/studio-ui/ui/app/src/models/PluginDescriptor.ts b/studio-ui/ui/app/src/models/PluginDescriptor.ts index dc31fac7..a5ca2a08 100644 --- a/studio-ui/ui/app/src/models/PluginDescriptor.ts +++ b/studio-ui/ui/app/src/models/PluginDescriptor.ts @@ -18,6 +18,8 @@ import type { ComponentType } from 'react'; import WidgetRecord from './WidgetRecord'; import type { DataSourceBinding, DataSourceModule } from '../components/FormsEngine/dataSources/types'; import type { ControlProps } from '../components/FormsEngine/types'; +import type { ValueRetriever, ValueSerializer } from '../components/FormsEngine/lib/controlValueTypes'; +import type { ValidatorFunctionDef } from '../components/FormsEngine/lib/validators'; /** * FE2 control contribution on a PluginDescriptor. @@ -28,6 +30,22 @@ export interface ControlPluginContribution { Component: ComponentType; /** Optional; same shapes as built-in `controlDataSourceBindings` entries. */ dataSourceBindings?: DataSourceBinding | readonly DataSourceBinding[]; + /** + * Optional XML → form value conversion for this control type. + * Must be registered before form bootstrap parses content (FE preloads plugin locators). + */ + valueRetriever?: ValueRetriever; + /** + * Optional form value → XML-serialiser shape for this control type. + * Used on save via `valueSerializers`; falls back to pass-through when omitted. + */ + valueSerializer?: ValueSerializer; + /** + * Optional type-specific validator for this control type. + * Required/empty checks remain host-owned in `validateFieldValue`. + * FE preloads plugin locators before form bootstrap so validators are registered in time. + */ + validator?: ValidatorFunctionDef; } export interface PluginDescriptor { diff --git a/studio-ui/ui/app/src/services/plugin.ts b/studio-ui/ui/app/src/services/plugin.ts index c2c0fb53..ba3ced60 100644 --- a/studio-ui/ui/app/src/services/plugin.ts +++ b/studio-ui/ui/app/src/services/plugin.ts @@ -34,7 +34,9 @@ import { registerControlContribution } from '../components/FormsEngine/controls/registry'; import { controlMap } from '../components/FormsEngine/lib/controlMap'; +import type { ValueRetriever, ValueSerializer } from '../components/FormsEngine/lib/controlValueTypes'; import type { ControlProps } from '../components/FormsEngine/types'; +import type { ValidatorFunctionDef } from '../components/FormsEngine/lib/validators'; const DEFAULT_FILE_NAME = 'index.js'; @@ -42,6 +44,9 @@ type ControlContributionCommit = { typeKey: string; Component: ComponentType; bindings: readonly DataSourceBinding[]; + valueRetriever?: ValueRetriever; + valueSerializer?: ValueSerializer; + validator?: ValidatorFunctionDef; }; function isPluginFileBuilder(target: any): target is PluginFileBuilder { @@ -217,6 +222,19 @@ export function registerPlugin(plugin: PluginDescriptor, source?: PluginFileBuil `Plugin "${plugin.id}" control "${typeKey}" must declare a valid React Component on ControlPluginContribution.Component.` ); } + if (entry.valueRetriever != null && typeof entry.valueRetriever !== 'function') { + throw new TypeError( + `Plugin "${plugin.id}" control "${typeKey}" valueRetriever must be a function when provided.` + ); + } + if (entry.valueSerializer != null && typeof entry.valueSerializer !== 'function') { + throw new TypeError( + `Plugin "${plugin.id}" control "${typeKey}" valueSerializer must be a function when provided.` + ); + } + if (entry.validator != null && typeof entry.validator !== 'function') { + throw new TypeError(`Plugin "${plugin.id}" control "${typeKey}" validator must be a function when provided.`); + } const bindings = normalizeDataSourceBindings(entry.dataSourceBindings ?? []); const existing = getRegisteredControlContribution(typeKey); if (existing) { @@ -231,7 +249,14 @@ export function registerPlugin(plugin: PluginDescriptor, source?: PluginFileBuil ); } } - controlsToCommit.push({ typeKey, Component: entry.Component, bindings }); + controlsToCommit.push({ + typeKey, + Component: entry.Component, + bindings, + valueRetriever: entry.valueRetriever, + valueSerializer: entry.valueSerializer, + validator: entry.validator + }); } } @@ -239,8 +264,15 @@ export function registerPlugin(plugin: PluginDescriptor, source?: PluginFileBuil dsToCommit.forEach((module) => { dataSourceModuleRegistry.register(module); }); - controlsToCommit.forEach(({ typeKey, Component, bindings }) => { - registerControlContribution(typeKey, { Component, bindings, pluginId: plugin.id }); + controlsToCommit.forEach(({ typeKey, Component, bindings, valueRetriever, valueSerializer, validator }) => { + registerControlContribution(typeKey, { + Component, + bindings, + pluginId: plugin.id, + valueRetriever, + valueSerializer, + validator + }); registerControlDataSourceBindings(typeKey, bindings); });