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