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);
});