Embedding the Form Editor
This page covers host integration: mounting the designer (FormEditor), mounting the runtime (FormRenderer), registering custom field types, and the extension points a host reaches for when building its own field renderers or property panels. For the JSON shape both components read and write, see Schema; for the linkage/expression model, see Linkage.
FormEditor
The fastest way to embed the designer is the pre-composed FormEditor:
import type { FormEditorApi, FormSchema } from "@vef-framework-react/form-editor";
import { FormEditor } from "@vef-framework-react/form-editor";
import { useRef } from "react";
function FormDesignerPage() {
const apiRef = useRef<FormEditorApi>(null);
return (
<div style={{ width: "100%", height: "100%", minHeight: 0 }}>
<FormEditor
apiRef={apiRef}
initialSchema={initialSchema}
onPublish={(schema: FormSchema) => saveSchema(schema)}
/>
</div>
);
}
FormEditor fills its container (width: 100%; height: 100%), so the host is responsible for giving it a sized box — a flex/grid cell with a definite height, not an auto-height page flow.
Notable FormEditorProps (the full table is in the Reference):
initialSchemaandregistries/registryare read once, on mount — updating them later has no effect. UseFormEditorApi.setSchemato replace the document after mount.onSchemaChangefires with the newFormSchemaon every committed edit;onPublish(paired withpublishText/publishLoading) runs a validation pass first — errors block publishing, warnings ask the designer for confirmation — and the publish button is not rendered at all whenonPublishis omitted.apiRefexposesFormEditorApi:getSchema/setSchema,undo/redo/canUndo/canRedo,selectNode,setDevice,setViewMode.evaluators,dataSourceResolver,evaluationContext,contextSourcesforward into the editor's own live preview — see Linkage and Loading remote options below.
The built-in toolbar, palette, and properties panel chrome ships with Chinese UI text (labels like "发布" / "导入" / "导出"). brand, publishText, and onPublish are the toolbar's customization points; a host that needs different chrome copy throughout composes its own shell (next section) around the same panels, or supplies its own toolbar entirely.
Composing your own shell
FormEditor is Object.assign'd with its parts, so a host that needs a different arrangement — a custom toolbar, relocated panels — composes the same pieces directly instead of the pre-built layout:
import { FormEditor } from "@vef-framework-react/form-editor";
function CustomDesignerShell() {
return (
<FormEditor.Provider initialSchema={initialSchema} apiRef={apiRef}>
<FormEditor.Shell>
<MyToolbar />
<FormEditor.Workspace>
<FormEditor.Palette />
<FormEditor.Stage />
<FormEditor.Properties />
</FormEditor.Workspace>
</FormEditor.Shell>
</FormEditor.Provider>
);
}
FormEditor.Provider(also exported standalone asFormEditorProvider) is the context root: the editor store, the per-device field registries, and the preview runtime (evaluators / data source resolver / evaluation context). It accepts everythingFormEditorPropsdoes except the toolbar-only props (brand,onPublish,publishText,publishLoading).FormEditor.Shell(FormEditorShell) sets up layout-adaptivity measurement, the device context, the shared drag-and-drop context, and shell-scoped keyboard shortcuts (undo/redo, etc.). It takes onlychildren.FormEditor.Workspace(FormEditorWorkspace) is the horizontal palette / stage / properties band.FormEditor.Stage(FormEditorStage) is the canvas column (design surface + the form-config drawer + the edit-mode status footer). It takes no props — it reads everything from the store.FormEditor.Toolbar(also exported asFormEditorToolbar) is the default toolbar, usable standalone if you only want to swap its brand.FormEditor.PaletteandFormEditor.Propertiesare the field palette and the properties panel; both read from context and take no props.
Field registries
Every field and container type the editor and renderer know about lives in a FormFieldRegistry. createDefaultRegistry() builds a PC registry pre-populated with the ~20 built-in types (textfield, number, select, subform, grid, …) and the antd container chrome; createDefaultMobileRegistry() builds the antd-mobile equivalent. FormEditor uses these when no registry is supplied.
import { createDefaultRegistry, FormEditor } from "@vef-framework-react/form-editor";
const registry = createDefaultRegistry();
registry.register(ratingFieldDefinition);
<FormEditor registry={registry} />;
registryis a shorthand for one registry shared by both devices;registries({ pc, mobile }) lets the two devices carry different field sets. Supply only the device(s) you customize — an omitted device falls back toregistry, then to the built-in defaults.- Register a custom field type with
defineFieldDefinition(leaf fields — requires aComponent) ordefineContainerDefinition(containers — renders structurally, so it takes noComponent). See Reference → Definition builders for theFieldDefinitionshape and Schema → Leaf fields for theFormFieldTypeMapmodule-augmentation pattern a custom type's own schema interface plugs into. registerDefaults(registry)applies the built-in field set and PC container chrome to an existing registry (without building a fresh one), for a host composing its own base + built-ins.FormFieldRegistryis a small mutable class:register/unregister/get/has/list,registerPropertyEntry/getPropertyEntry(design-time property-panel renderers, keyed byEntryType— see Reference), andsetContainerChrome/getContainerChrome. Subscribers (subscribe) are notified on every mutation, so the palette and properties panel update live if you register fields after mount.
Swapping container chrome
section / tabs / subform render through a ContainerChromeSet — six presentational shell components (Section, Tabs, Subform, SubformRow, AddButton, RemoveButton) resolved from the active registry. flex and grid are pure CSS layout and use no chrome. To restyle containers without touching field-level rendering, build a ContainerChromeSet and call registry.setContainerChrome(chrome) on your own registry before passing it to FormEditor / FormRenderer.
One prop is easy to drop and must not be: SubformChromeProps.errors (string[]) has to be rendered. A subform mounts a single field for the whole array, so its own errors — "at least one row" on a required subform, and a table variant's rolled-up row error — have no other slot; a chrome that ignores them blocks submission with nothing on screen. Both built-in chromes render them beneath the body, through the same footer a leaf field uses.
FormRenderer
FormRenderer renders a FormSchema as a live, data-bound form — this is what an end user (not the form's designer) interacts with:
FormRenderer only installs DeviceProvider internally — unlike FormEditor, it does not supply a RegistryProvider. Wrap it (or a shared ancestor) in RegistryProvider with the device registries it should render against; without one, every field throws "A <RegistryProvider> ancestor is required...".
import type { FormSchema } from "@vef-framework-react/form-editor";
import { createDefaultMobileRegistry, createDefaultRegistry, FormRenderer, RegistryProvider } from "@vef-framework-react/form-editor";
function ApplicationForm({ schema }: { schema: FormSchema }) {
return (
<RegistryProvider registries={{ pc: createDefaultRegistry(), mobile: createDefaultMobileRegistry() }}>
<FormRenderer
schema={schema}
onSubmit={values => submitApplication(values)}
/>
</RegistryProvider>
);
}
onSubmit receives the submitted values as Record<string, unknown> — the form has no compile-time value type, since its shape is only known at runtime from the schema.
Notable FormRendererProps (full table in the Reference):
device(default"pc") selects which presentation to render. An undesigned device (e.g. a form with no mobile design) renders an empty state rather than falling back to the other device. Themobilepresentation always renders its antd-mobile controls under azh-CNlocale, regardless of the host application's own locale.defaultValues,disabled,onSubmitbehave like an ordinary form:onSubmitonly fires after the schema's own validation rules pass.fieldPermissions(v2.10.0) is a server-resolved clamp per field key (Record<string, FieldPermission>) — the outer bound that linkage may narrow within but never widen past ("hidden"unmounts a field entirely,"visible"renders it read-only,"required"forces the empty check,"editable"is unclamped). The clamp also gates every programmatic write (assign/set_field) and filters the submit payload down to writable keys. The canonical source is the approval engine resolving one approval node's permission matrix for the current viewer, but any host-resolved map works. See Linkage → Field permission clamp.apiRefexposesFormRendererApi:submit()(runs the same validation pipeline as a schemasubmitbutton),reset(),getValues()(the raw live state), andgetSubmitValues()(the exact filtered payloadonSubmitwould receive — v2.10.0) — for host chrome like a modal footer's confirm button.evaluators,dataSourceResolver,evaluationContextmirror the editor's props of the same name and drive the exact same linkage engine — see Linkage.containOverlayspins mobile overlay pickers (masks/sheets) to the renderer's own box instead of the browser viewport — only meaningful withdevice="mobile", for a desktop "phone frame" preview.
Loading remote options
A select / radio / checkbox-group field's dataSource can be static (inline options, resolved synchronously), ref (a form-global FormSchema.dataSources entry), or remote (an RPC-shaped request resolved at render time). The package has no networking dependency of its own — remote sources resolve through a host-supplied DataSourceResolver:
import type { DataSourceResolver } from "@vef-framework-react/form-editor";
import { createApiRequest, HTTP_CLIENT } from "@vef-framework-react/core";
// `RemoteDataSourceRequest` mirrors the core `apiClient`'s resource/action/version
// addressing, so `createApiRequest` builds the same envelope a `createQueryFn`
// call would — only here the resource/action come from the field at render time
// instead of being fixed ahead of time.
const resolver: DataSourceResolver = {
resolve: async ({ action, params, resource, version }, mapping) => {
const result = await apiClient[HTTP_CLIENT].post("/api/rpc", {
data: createApiRequest(resource, action, version ?? "v1", params)
});
return (result.data as Array<Record<string, unknown>>).map(item => ({
label: String(item[mapping?.labelKey ?? "label"]),
value: item[mapping?.valueKey ?? "value"] as string | number
}));
}
};
<RegistryProvider registries={{ pc: createDefaultRegistry(), mobile: createDefaultMobileRegistry() }}>
<FormRenderer schema={schema} dataSourceResolver={resolver} />
</RegistryProvider>;
Passing no resolver (the default, noopDataSourceResolver) makes every remote source resolve to an empty list rather than error. If you are building a custom field component that needs the same resolution logic (static / ref-to-static synchronous, remote / ref-to-remote fetched with loading/error state and result caching), call useFieldOptions(field.dataSource) inside a component wrapped by the same DataSourceProvider the editor and renderer already install — you rarely need to install DataSourceProvider yourself.
Bound parameters
RemoteDataSourceRequest.params is Record<string, DynamicValue> — each parameter is either { kind: "literal"; value } or { kind: "expression"; source } bound to the live form. Binding one is what makes a cascading select work — "the wards of the department picked above":
{
resource: "sys/ward",
action: "find_options",
params: {
departmentId: { kind: "expression", source: "field.deptId" },
active: { kind: "literal", value: true }
}
}
The runtime evaluates expressions before the request reaches the resolver — through evaluateAssignExpression, the same seam a linkage assign value uses, so one expression language covers both — and hands over a ResolvedDataSourceRequest ({ resource, action, version?, params?: Record<string, unknown> }) carrying plain data. A resolver never learns that an expression existed and stays transport-only. DynamicValue is one declaration shared with linkage action values, so the designer offers the same "fixed value or bound value" control in both places.
A parameter that evaluates to undefined is omitted rather than sent — an expression reading a key the form does not carry, or any expression at all when no evaluator resolves. Sending the key with an undefined value would put a hole on the wire the backend has to special-case; omitting it says the same thing in the vocabulary every transport already understands. An empty value the form really holds ("") is a value, not an absence, and is sent.
Options are cached on the resolved request, so a bound value changing re-resolves the list while an unrelated keystroke does not — and a schema that binds no parameter anywhere never opens the form-values subscription in the first place.
Per-row parameter scopes
A bound parameter is evaluated against its own value scope: the root form for a root-level field, and the row's own record for a field inside a subform row. Two rows that pick different departments therefore issue two different requests and get two different ward lists.
Author a per-row cascade exactly as you would at the root — the expression names the row's sibling key, because field / $form bind the current scope's values inside a row (see Linkage → Expression and script evaluation):
{
type: "subform",
key: "lines",
template: [
{ type: "textfield", key: "deptId", label: "Department" },
{
type: "select",
key: "wardId",
label: "Ward",
dataSource: {
kind: "remote",
request: {
resource: "sys/ward",
action: "find_options",
params: { departmentId: { kind: "expression", source: "field.deptId" } }
}
}
}
]
}
Option resolution previously evaluated bound parameters against the whole form regardless of the field's scope. Inside a subform row that meant the expression read a root key that does not exist, so the parameter dropped out — and because the option cache is keyed on the resolved request, every row issued the identical unfiltered request and collapsed onto one shared option list. Per-row cascading was structurally impossible rather than merely wrong once. The api_call effect lane already scoped itself this way; both lanes now answer identically.
Building custom field renderers
A field definition's Component receives FieldComponentProps (field, value, onChange, errors, domId, disabled, required, labelPosition) and is free to render anything, but a few exported primitives keep a custom field visually consistent with the built-ins:
FieldShell/Labelimplement the shared label-vs-control layout (top / left / right label placement, helper text, error band) that most built-in input-like fields use — a few (e.g.switch) composeLabeland the field footer directly instead of going throughFieldShell. PasslabelledBy="group"when your field renders a set rather than one labelable control (an option group, a dropzone):<label for>is inert against a wrapperdiv, so without it the field has no accessible name at all — a screen reader announces the first option and never the question. The shell then labels the body as arole="group"and carriesaria-required/aria-invalidonto it. The built-ins using it areradio,checkbox-group,code-editor, andupload.useFieldRegistry()anduseDeviceRegistries()(from the sameDeviceProvider/RegistryProvidercontext) resolve the active device's registry — useful for a custom container or a field that needs to inspect sibling definitions.FormEditorinstalls both providers itself;FormRendererinstalls onlyDeviceProvider, so a host mountingFormRendererstandalone must supply its ownRegistryProviderancestor (see FormRenderer above).useMobileScopeContainer()returns agetContainercallback for antd-mobile popup-based controls (Picker,DatePicker, …). It resolves to the surrounding phone-frame element when one is contained (the editor's own design-time preview), and falls back todocument.bodyat real mobile runtime — wire it into a custom mobile field's popup the same way the built-in mobile fields do.
See Reference for defineFieldDefinition, defineContainerDefinition, and the full FieldComponentProps / FieldDefinitionConfig shapes.
Full example
A route mounting FormEditor with host context wired in (from the framework's own playground):
import type { EvaluationContext, LinkageContextSource } from "@vef-framework-react/form-editor";
import { FormEditor } from "@vef-framework-react/form-editor";
// Host-injected runtime context; expressions and `$`-rooted visual conditions
// read it via `$user.*`.
const EVALUATION_CONTEXT: EvaluationContext = {
user: {
id: "user-admin",
departmentId: "dept-finance",
departmentName: "Finance"
}
};
// Design-time pick list: these paths appear in the visual condition builder's
// source dropdown alongside the form's own fields.
const CONTEXT_SOURCES: LinkageContextSource[] = [
{ key: "$user.departmentId", label: "Applicant department ID" },
{ key: "$user.departmentName", label: "Applicant department" }
];
function FormDesignerRoute() {
return (
<FormEditor
contextSources={CONTEXT_SOURCES}
evaluationContext={EVALUATION_CONTEXT}
initialSchema={initialSchema}
onPublish={schema => saveSchema(schema)}
/>
);
}