Skip to main content

Form Schema

A FormSchema is the single artifact FormEditor produces and FormRenderer consumes — a plain, JSON-serializable object. This page covers its shape; see Linkage for the conditional-behavior model attached to individual blocks, and Reference for exhaustive export tables.

Top-level shape​

interface FormSchema {
id: string;
version: 2;
variables?: FormVariable[];
dataSources?: FormDataSource[];
linkage?: FieldLinkage; // form-scope "events" — see Linkage
presentations: {
pc: PresentationLayer;
mobile?: PresentationLayer;
};
}

variables, dataSources, and the form-scope linkage are the shared data layer — one set for the whole form, regardless of device. presentations holds each device's independent layout. Create an empty document with createEmptySchema() (mints a fresh id and an empty pc presentation).

Per-device presentations​

interface PresentationLayer {
gap?: GapScale; // "small" | "medium" | "large", default "medium"
children: Block[];
}

pc and mobile are independent block trees — a form can be laid out completely differently on each device while both bind the same variables / dataSources and the same field keys. pc always exists; mobile is undefined until a design is started for it, and FormRenderer shows an empty state for an undesigned device rather than falling back to pc.

The design differs; the data layer does not. A field may exist on one device only, and a field both devices carry may sit in a different container or a different order on each — all legitimate. A forked key is not: the two trees name one value, so setFieldKey renames the twin on the other device (the field bound to the same key at the same value scope) inside the same undo step, and allocates the new key against the keys both trees already bind at that scope, suffixing _2, _3, … on a collision. Letting the two drift would hand the backend a phantom — the approval projection folds root-scope keyed nodes from both devices into one inventory deduplicated by key, so a fork projects one field as two.

Two limits are worth knowing. validateSchema checks id and key uniqueness per presentation — each device tree keeps its own namespace — so the cross-device guarantee comes from the editor store's rename action, not from validation. And key minting (dropping a new field, duplicating one) still allocates against the active device's layer alone.

A few helpers bridge the device-keyed FormSchema and the flat PresentationLayer the engine walks:

ExportSignaturePurpose
currentLayer(schema, device) => PresentationLayerThe editable layer for a device; an undesigned mobile resolves to a stable shared empty layer.
resolvePresentation(schema, device) => PresentationLayer | undefinedSame, but undefined (not a fallback) for an undesigned device — the render-time semantic.
withPresentation(schema, device, layer) => FormSchemaWrite a layer back to one device, leaving the other and the shared data layer untouched.
emptyLayer() => PresentationLayerThe shared empty-layer constant.
toRuntimeSchema(schema, device) => RuntimeSchema | undefinedFlatten one device's layer plus the shared data layer into the RuntimeSchema the renderer and linkage engine actually evaluate.

PC-to-mobile conversion​

convertPresentation(pc, rules, targetRegistry) produces a best-effort, single-column mobile starting point from a PC layer: it strips PC-only layout (span / flex), reflows flex / grid containers by unwrapping them (their children splice in place, since a phone has no side-by-side layout), degrades code-editor to textarea (no mobile code control), and drops anything the mobile registry has no type for — returning a ConversionReport of what converted and what was dropped so the host can surface it. createDefaultConversionRules() builds the rule set the editor uses by default; extend it via new ConversionRegistry().register({ type, convert }) for a custom field's own conversion behavior.

The block tree​

Every node in a PresentationLayer.children array — and every node nested inside a container — is a block: either a leaf field (FormField) or a container (ContainerNode).

type Block = FormField | ContainerNode;

Blocks stack vertically in document-flow order. There is no implicit multi-column row — side-by-side placement is always an explicit grid or flex container the author drags in. This keeps the tree shape and the visual layout in agreement without a separate "row" concept to keep in sync.

Leaf fields​

FormField is the union of every registered leaf type, keyed by its type discriminator (FormFieldTypeMap). Unlike containers, this map is open — a host adds its own field type's interface to the union by augmenting the module:

declare module "@vef-framework-react/form-editor" {
interface FormFieldTypeMap {
rating: RatingField;
}
}

paired with a FieldDefinition registered on a FormFieldRegistry (see Embedding → Field registries and Reference → Definition builders). The built-in types:

typeRegistry exportKeyedGroupNotes
textfieldtextfieldDefinitionyesbasic-inputsingle-line text; inputType: "password" masks it
code-editorcodeEditorDefinitionyesbasic-inputCodeMirror-backed multi-line code; lazy-loaded (heaviest dependency in the stack)
numbernumberFieldDefinitionyesbasic-inputdistinguishes input bounds (min/max, clamp the committed value) from validate.min/max (submit-time rule)
textareatextareaFieldDefinitionyesbasic-inputmulti-line text
switchswitchFieldDefinitionyesselectionboolean toggle; not Validatable — a switch always holds a value, so static required has no meaning (a require linkage rule still applies)
selectselectFieldDefinitionyesselectionsingle-select dropdown; dataSource is static / ref / remote
radioradioFieldDefinitionyesselectionsingle-select radio group; optionType: "button" renders a segmented group
checkbox-groupcheckboxGroupFieldDefinitionyesselectionmulti-select; contributes an array value
datedateFieldDefinitionyesdate-filecontributes a YYYY-MM-DD string
datetimedatetimeFieldDefinitionyesdate-filecontributes a YYYY-MM-DD HH:mm:ss string
daterangedateRangeFieldDefinitionyesdate-filecontributes a [start, end] string pair
uploaduploadFieldDefinitionyesdate-filestorage-backed file upload; contributes an object key (or a list of them) — see below. Registered by createDefaultRegistry() / registerDefaults() and exported from the package index
buttonbuttonDefinitionnoactionnon-keyed; action (html type, default "submit") is orthogonal to buttonType (visual style)
dividerdividerDefinitionnopresentationsection rule with an optional inline title
alert-blockalertBlockDefinitionnopresentationinline alert banner
paragraphparagraphDefinitionnopresentationstatic explanatory text

Every leaf field shares id, label, labelPosition ("top" | "left" | "right"), plus the layout sizing and linkage slots. Fields marked "keyed" additionally carry a key: string (the data-binding path) and an optional columnType override (see table-storage projection); most also implement Validatable (validate: { required, minLength, maxLength, min, max, pattern, message }) — switch is the one exception noted above. validate.minLength / validate.maxLength count characters (code points), matching the Go backend's utf8.RuneCountInString: an emoji or a supplementary-plane CJK character counts as one on both sides, so the limit the designer wrote once means the same thing in the browser and on the server. A field-level maxLength (on textfield / textarea) is a different axis — the native input cap that stops typing past N, enforced by the DOM and therefore counted in UTF-16 code units. The exact per-field props (placeholder, allowClear, size, dataSource, …) are documented per interface in the Reference.

mobileFieldDefinitions is the parallel antd-mobile renderer set — every built-in leaf type except code-editor (which has no mobile control; the PC → mobile converter degrades it to textarea), reusing each PC field's config and property-panel descriptors with only the rendered Component swapped.

The mobile date and datetime pickers are handed an explicit 1900-01-01 … 2099-12-31 23:59:59 range. antd-mobile's DatePicker otherwise defaults to thisYear ± 10 and clamps the seeded value into that window before rendering the wheel, so a stored birth date of 1980 opened on today and confirming without touching anything wrote today back — while the trigger, which formats the stored value directly, still showed 1980. Dates outside the window were simply unenterable, too. The PC DatePicker imposes no range and the value is shared across both presentations, so the mobile bound has to be wide enough not to constrain form data. The mobile daterange renders a CalendarPicker, which carries no bounds and was never affected.

Multi-value and upload fields​

A checkbox-group's value is ordered by the option list, not by click order. antd's Checkbox.Group sorts its emitted array by option index while antd-mobile's appends each check in the order it was ticked, so the same two clicks would otherwise produce ["read", "music"] on desktop and ["music", "read"] on a phone — two different stored rows for one user intent, and a spurious diff whenever someone resubmits from the other device. Both renderers normalize on change: the value is sorted into the option list's own order, and any entry whose option the list no longer offers is dropped, because the backend validates a selection against the field's enumerated options and rejects anything else. Normalization happens on the user's next toggle, so a stale value is not rewritten merely because a remote option list finished loading.

An upload field holds storage object keys, and its empty value has one shape. With maxCount at its default of 1 the value is a single key and empty is null; with maxCount > 1 it is an array of keys and empty is []. "Never touched" and "cleared by the user" are deliberately the same shape, so nothing downstream has to tell them apart. The storage column follows the same axis rather than the string rules — inferColumnType returns "json" when maxCount > 1 and "text" otherwise, and never consults validate.maxLength, which bounds a string's length where an upload's bound is its file count.

Containers​

Containers are a closed set — ContainerNode = SectionNode | TabsNode | SubformNode | FlexNode | GridNode. Unlike leaf fields, hosts cannot add a new container kind through the registry (each one has distinct value-scope and tree-walk semantics baked into the engine); a custom layout need generally fits inside flex or grid.

typeRegistry exportKeyedOpens a value scopeNotes
sectionsectionDefinitionnonovariant: "card" (antd Card, never collapses) or "collapse" (antd Collapse, defaultCollapsed)
tabstabsDefinitionnonoordered TabItem[] ({ id, label, children }); panes mount lazily
subformsubformDefinitionyesyesrepeating record group — see Subforms
flexflexDefinitionnonoCSS flexbox row/column; each child sized by its own flex: FlexSlot
gridgridDefinitionnonoCSS grid, columns (default 2, 1..24); each child's span widens it

section, tabs, flex, and grid are pure layout: they pass their enclosing value scope straight through to their children. Only subform opens a new one.

Subforms​

A subform binds an Array<Record<string, unknown>> under its own key; its template: Block[] is the per-row field set, and — uniquely among containers — opens a new value scope: a template field's key only has to be unique among its row siblings, not against the whole form (its runtime path is lines[i].amount, never colliding with a root amount). minRows (default 0) is the number of rows the form keeps present — the runtime seeds that many blank rows and blocks removing rows below it; maxRows (unbounded when omitted) disables the add control at that count. addLabel customizes the add control's text.

Two variants share this data contract and differ only in presentation:

  • stack (default) — each row renders its template as a normal vertical stack of fully editable, individually laid-out fields. Supports nesting and per-row linkage.
  • table — rows render through the components package's EditableTable (desktop only; the mobile presentation always falls back to stack). Since every template field becomes a column, a non-keyed or container block in a table subform's template flags subform_table_column — a warning (not a rejection); the renderer skips that block.

Value binding and keys​

A node that contributes data carries a key (KeyedNode); a keyed leaf field or a subform narrows to KeyedNodeUnion. Keys are unique per value scope, not globally — the root scope and each subform's template scope are independent namespaces.

ExportSignaturePurpose
isKeyedNode(node: Block) => node is KeyedNodeUnionStructural guard catching both keyed leaf fields and subform.
isKeyedField(field: FormField) => field is KeyedFormFieldSame, narrowed to the FormField union only.
createId(prefix) => stringMint a node id (Field_…, Section_…, Rule_…, …) via the shared cuid2 generator.
generateUniqueKey(layer, baseKey, scope?) => stringAllocate a scope-unique key, trying base, then base_2, base_3, …
nextUniqueKey(used: Set<string>, baseKey) => stringThe same allocation against an already-collected key set (for minting several keys in one pass).
sanitizeKey(key: string) => stringStrip every non-word character, so a user-typed key can never break value-path resolution.

Options and data sources​

A selection field's dataSource: FieldOptionSource is one of:

type FieldOptionSource =
| { kind: "static"; options: FieldOption[] } // inline, resolved synchronously
| { kind: "ref"; dataSourceId: string } // points at FormSchema.dataSources
| { kind: "remote"; request: RemoteDataSourceRequest; mapping?: RemoteOptionMapping } // inline RPC
| { kind: "codeSet"; codeSet: string }; // a host data dictionary
  • static — the options are inline and resolve synchronously. A selection field with no dataSource has an empty static list.
  • ref — points at an entry of FormSchema.dataSources by id. A ref to a static entry resolves synchronously; a ref to a remote entry resolves through the host's resolver and is the only kind of source a refresh_data_source action can re-fetch. A dangling id resolves to an empty list (the validator warns: data_source_ref_unknown).
  • remote — an inline request, resolved through the host's resolver.
  • codeSet — the entries of a host data dictionary, named by its key (数据字典 in the designer). Every dictionary a form reads loads in one batched call through codeSetQueryFn — by default the application's AppContext.codeSetQueryFn — and only a dictionary's top-level entries become options. An empty key is a warning (code_set_empty) and resolves to an empty list. See Embedding → Data dictionaries.

FormSchema.dataSources: FormDataSource[] holds the form-global, reusable sources — { id, name, kind: "static", options } or { id, name, kind: "remote", request, mapping? } — that ref sources point at. The designer edits them in the 数据源 tab of the form-config drawer, and deleting one there prunes every ref to it and every refresh_data_source action naming it in the same undo step.

RemoteDataSourceRequest ({ resource, action, version?, params? }) mirrors the framework's resource / action / version RPC addressing and is transport-agnostic — the package has no apiClient dependency; a host resolves it via a DataSourceResolver (see Embedding → Loading remote options). Each entry of params is a DynamicValue, so a parameter can be bound to a form value — that is how a cascading select is authored, per row inside a subform included (see Bound parameters).

RemoteOptionMapping ({ labelKey?, valueKey?, disabledKey?, descriptionKey? }, mirroring the field mapping of @vef-framework-react/hooks' useDataOptionsQuery) tells a resolver how to read { label, value } out of each raw record; labelKey / valueKey default to "label" / "value". A blank key means "use the default", but a resolver reading mapping.labelKey ?? "label" would take "" literally and look every label up under a property named "". So mappings are normalized with the exported normalizeOptionMapping — whitespace-only keys dropped, the rest trimmed, undefined when nothing is left — wherever one is stored, resolved, or projected: the designer never stores a blank key, the runtime normalizes before calling the resolver, and the approval projection normalizes optionSource.mapping. A schema that already carries "" keys therefore still resolves correctly.

A host can also hand the designer a catalog of ready-made endpoints (DataSourceApi[]) so a designer picks "Department list" instead of typing a resource and a mapping. The catalog is design-time only: a pick is stored as an ordinary request and mapping, so a schema never references the catalog — see Embedding → Offering an API catalog.

Form variables​

FormSchema.variables: FormVariable[] declares the form-global variables that expressions and $vars-rooted conditions read as $vars.<name>:

interface FormVariable {
id: string;
name: string; // letters, digits, underscore; no leading digit
type: "string" | "number" | "boolean" | "json";
defaultValue?: unknown;
}

The renderer seeds $vars from each variable's defaultValue, lays the host's evaluationContext.variables over the seed, and lets set_variable actions write it; the seed is re-applied only when its content changes, so a host re-render never clobbers a set_variable write. Names must be unique and identifier-shaped — $vars.1total would not even parse — which the validator enforces (variables_invalid). Renaming a variable in the designer's 变量 tab rewrites every set_variable action that targets it; expression sources are opaque text and are not rewritten. Deleting a variable removes only the declaration: a set_variable to an undeclared name is still valid, since $vars accepts any name at runtime.

Variables are the form's own, and writable. Values the host supplies — the viewer, the initiator, tenant settings — belong in the evaluation context instead, which the designer lists as read-only system variables beside the form's own.

Layout and sizing​

A block carries at most one meaningful layout prop, selected by its parent — the type system does not prevent setting more than one, but only the one the parent honors has any effect:

PropHonored when the block isShape
spana grid cellinteger 1..24 (ROW_COLS); omitted means one column
flexa flex child{ grow?, shrink?, basis? }
columnWidtha table subform columnfixed pixel width; omitted shares remaining width
stacka direct child of a stack body (root / section / tabs pane / stack subform row){ width?, minWidth?, maxWidth?: CssLength, align?: "start" | "center" | "end" }, CssLength = { value: number; unit: "px" | "%" }

stack: StackSlot (v2.10.0) sizes and places a block in the one parent context that otherwise leaves it full-width — the stack body. Field by field:

StackSlot fieldTypeDefaultEffect
widthCssLengthomitted resolves to 100%The block's CSS width. % is relative to the stack's width, px is absolute. value must be ≥ 1 — a zero box would collapse the block.
minWidthCssLengthomitted = CSS default (0)CSS min-width floor; a value of 0 is valid here (unlike width / maxWidth).
maxWidthCssLengthomitted = unconstrainedCSS max-width cap; value must be ≥ 1. Combine with a % width for responsive sizing (e.g. width: 100% capped by maxWidth: 480px).
align"start" | "center" | "end"omitted = document flow (reads as start)Cross-axis placement, realized as auto margins, so it only bites once a width / maxWidth narrows the block below the stack's width — a no-op at full width.

The slot is ignored in a grid / flex / table-subform parent, where span / flex / columnWidth apply instead. validateSchema checks the shape on import (stack_invalid): a non-record slot, an out-of-range or malformed CssLength, or an unknown align fails. Lengths are authored as explicit { value, unit } — never raw CSS strings — so a persisted length is always a valid, re-editable dimension.

A stacking body's own vertical rhythm is gap: GapScale ("small" | "medium" | "large", DEFAULT_GAP_SCALE = "medium", valid values in GAP_SCALES) on the PresentationLayer itself or on a section / tabs / stack-subform — omitted means inherit the form-level (or subform-level) default. linkage (see Linkage) is honored on any block, leaf or container.

Editing the tree​

Every mutator in this section is pure (returns a new PresentationLayer) and follows one identity contract: a no-op returns the exact input reference, and a successful edit rebuilds only the path from the root to the change, so every untouched branch keeps its previous object identity. This is what lets the canvas and the runtime renderer memoize blocks by reference at hundreds of fields.

ExportSignaturePurpose
insertBlock(layer, block, target: DropTarget) => PresentationLayerInsert a block at a drop target ({ kind: "beside", anchorId, side }, { kind: "container", containerId, tabIndex? }, or { kind: "append" }).
moveBlock(layer, nodeId, target) => PresentationLayerMove an existing block; re-keys it if the move crosses a value scope. Transactional — rolls back on a target that doesn't land.
cloneBlock(block, allocateKey: ((base: string) => string) | null) => BlockDeep-clone with fresh ids throughout; allocateKey reallocates keys in the clone's own scope, null preserves them verbatim (used inside an already-isolated subform template).
setSpan / setFlex(layer, blockId, value) => PresentationLayerNormalize-and-write a block's grid span / flex slot.
editField(layer, fieldId, updater: (field: FormField) => FormField) => PresentationLayerApply a pure updater to one leaf field, matched by id.
removeBlock(layer, id) => PresentationLayerRemove a block anywhere in the tree. Does not prune dangling linkage references to its key — that is an editor-store concern layered on top.
updateNode(layer, id, updater: (node: Block) => Block) => PresentationLayerApply a pure updater to any node (field or container), matched by id.

Read/query the tree with walkNodes / walkFields (depth-first visitors receiving each node plus its enclosing scope — the chain of subform keys it sits under), findNode / findField / findParentContainer, and the isContainerNode / isLeafField type guards.

Validating an imported schema​

FormEditor's own mutators always produce a well-formed tree. validateSchema(candidate: unknown, registries: DeviceRegistries): ValidateSchemaResult is the boundary guard for schema that did not come from the editor — a pasted import, an API payload. It checks, in order:

  • The envelope — a non-empty id, version: 2, well-formed variables (unique non-empty ids, unique identifier-shaped names, a known type) and dataSources (unique non-empty ids, a string name, a valid static option list or remote request and mapping).
  • Each presentation, against that device's registry — pc always, mobile when present, each with its own id and key namespace: every block a plain object with a unique id and a known type; keyed types carry a key and non-keyed types and layout containers do not; keys match ^\w+$ and are unique within their value scope; display texts (label, placeholder, helperText, title, text, message) are strings; span, flex, columnWidth, stack, container gap / columns / variant / minRows / maxRows are in range; a table subform's template holds keyed leaves only (subform_table_column, a warning); validate rules are consistent (min ≤ max, minLength ≤ maxLength, a compilable pattern); and option sources are well-formed, with ref ids resolved against dataSources and each remote request checked for a resource and an action.
  • Linkage — once a presentation's structural pass found no error, validateLinkageSchema runs over it (see Linkage → Validation); the form-scope linkage is checked with the pc tree.

A result is valid (and carries the narrowed schema) as long as it has no error-severity issue — warning-severity issues (dangling references, an unfinished remote request, an empty condition group) are legitimate mid-authoring state and round-trip through export → import. Every code is listed in the Reference.

Where validation runs, and where it does not:

  • The designer's 导入 dialog and its editable JSON view share one apply pipeline: errors block and are listed inline, warnings apply with a summary toast. Neither runs the host's validate; that runs in the editor's diagnostics and publish gate (see Embedding → Host diagnostics).
  • The designer's status bar and publish gate run it live over the current schema.
  • initialSchema, FormEditorApi.setSchema, and FormRenderer do not validate. A schema from an untrusted source should pass validateSchema first — the renderer degrades rather than crashes on most malformed linkage, but it assumes a well-formed tree.

Table-storage projection​

For a host that persists submitted values in a relational column per keyed field, toColumnDefinitions(schema): ColumnDefinition[] projects every root-scope keyed leaf field (deduplicated across both device presentations, pc winning a collision) to a { key, columnType, maxLength?, precision? } record. columnType: ColumnDataType ("string" | "text" | "integer" | "decimal" | "boolean" | "date" | "datetime" | "json") is inferred deterministically from the widget by inferColumnType(field), honoring a field's explicit columnType override first — only number (integer vs. decimal, by precision), upload (json vs. text, by maxCount), and the string family (sized string vs. unbounded text, by validate.maxLength) are ambiguous enough to need a rule of their own.

Approval flow editor bridge​

toFormFieldDefinitions(schema): FormFieldDefinition[] flattens the same root-scope keyed field inventory into the coarse { key, kind: FieldKind, label, options? } shape @vef-framework-react/approval-flow-editor consumes through its EditorPlugins.formFields seam to drive its per-node field-permission matrix. FieldKind is a lossy, lowest-common-denominator set ("input" | "textarea" | "select" | "number" | "date" | "upload") shared with the Go backend's FieldKind.

This mapping is intentionally simple and is not the authoritative approval integration path — for approval-backend integrations, project the schema with projectFormSchema from @vef-framework-react/approval-form-bridge instead. It walks the schema once and derives a richer FormFieldDefinition[] (as formFields) alongside the flat ApprovalFormField[] the Go backend's own parser expects (as fields), with a conservation rule — an unprojectable keyed field is always a reported error, never a silent drop. Where this function collapses subforms away and folds every widget into a nearest kind, the bridge projects a subform as a table entry with its columns and annotates linkage-hideable fields with hasConditionalVisibility for the flow editor's permission table.