ModelForm
Metadata-driven create/edit form container based on react-hook-form and Zod.
Related Docs
Import
import { ModelForm } from "@/components/views/form/ModelForm";Quick Start
Recommended usage in src/app/**/[id]/page.tsx:
import { UserAccountUnlockActionDialog } from "@/app/user/user-account/components/user-account-unlock-action-dialog";
import { Action } from "@/components/actions/Action";
import { FormSection } from "@/components/views/form/components/FormSection";
import { Field } from "@/components/fields";
import { FormBody } from "@/components/views/form/components/FormBody";
import { FormHeader } from "@/components/views/form/components/FormHeader";
import { FormToolbar } from "@/components/views/form/components/FormToolbar";
import { ModelForm } from "@/components/views/form/ModelForm";
export default function EditUserAccountPage() {
return (
<ModelForm modelName="UserAccount">
<FormHeader />
<FormToolbar>
<Action
label="Lock Account"
operation="lockAccount"
placement="more"
confirmMessage="Lock this user account?"
successMessage="User account locked."
/>
<Action
type="dialog"
label="Unlock Account"
operation="unlockAccount"
placement="more"
successMessage="User account unlocked."
component={UserAccountUnlockActionDialog}
/>
</FormToolbar>
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</FormSection>
</FormBody>
</ModelForm>
);
}ModelForm now provides runtime/provider + page shell spacing, and automatically resolves route id:
params.id === "new"=> create mode (id = null)params.idexists and is not"new"=> edit mode- if route has no
idparam => create mode by default
Validation behavior:
- default is
onBlur reValidateModeisonChange
Dialog Mode (Action type=“form”)
ModelForm can run inside a dialog when opened via <Action type="form" />. In this mode it automatically adapts:
- ID resolution: ignores route
params.id(uses only theidprop; defaults to create mode) - Create/update success: closes the dialog instead of
router.push - Cancel: closes the dialog instead of navigating back
- relatedField injection: the parent record
idis merged intodefaultValuesas{ [relatedField]: parentId }and included in the API payload — even if the field is not displayed in the form
No special props are needed on ModelForm itself — dialog mode is detected automatically via ActionFormRuntimeContext.
Example:
// Parent form page
<FormToolbar>
<Action
type="form"
label="Add Config Group"
placement="toolbar"
component={ConfigGroupForm}
relatedField="tenantConfigId"
/>
</FormToolbar>
// Child form component (used as Action.component)
function ConfigGroupForm() {
return (
<ModelForm modelName="TenantConfigGroup">
<FormToolbar />
<FormBody enableAuditLog={false}>
<FormSection label="General" hideHeader>
<Field fieldName="groupName" />
<Field fieldName="description" />
{/* tenantConfigId is not displayed but is auto-injected into the API payload */}
</FormSection>
</FormBody>
</ModelForm>
);
}Need custom variations? Use useModelFormContext() in children and rearrange FormHeader/FormToolbar/FormBody directly.
Canonical field usage now lives in Fields. Widget compatibility and widget-specific examples live in Widget matrix. Relation field behavior lives in Relation fields. Use those documents for:
Fieldprops and metadata overridesFieldType -> WidgetTypecompatibility- widget-specific
widgetProps - relation field behavior (
Reference,OneToMany,ManyToMany)
The quick examples below are kept as local shortcuts, but the fields README is the source of truth.
Default recommendation is Field (metadata auto-dispatch by fieldType) with metadata overrides and condition-based control.
Example metadata overrides on Field:
<Field
fieldName="name"
label="Custom Label"
readonly
required={false}
hideLabel={true}
fullWidth={false}
widgetType="URL"
filters={[["active", "=", true]]}
defaultValue="https://example.com"
/>Field.defaultValue is a create-time field override. Prefer it for static page-specific defaults; keep dialog/page defaultValues for dynamic prefills such as route params or parent-context values.
When you do pass container-level defaultValues, use field UI values directly:
File:FileInfo | nullMultiFile:FileInfo[]JSON/DTO: structured object/array valuesFilters:FilterConditionOrders: structured order tuples/arrays
Detailed field value contracts are documented in Field.
Example conditional field control:
import { dependsOn, Field } from "@/components/fields";
<Field fieldName="status" readonly={true} />
<Field fieldName="itemTone" hidden={["active", "=", false]} />
<Field
fieldName="description"
readonly={[
["status", "IN", ["approved", "archived"]],
"OR",
[["type", "=", "SYSTEM"], "AND", ["editable", "!=", true]],
]}
/>
<Field
fieldName="label"
required={dependsOn(["active", "itemCode"], ({ values, isEditing }) =>
!isEditing && values.active === true && values.itemCode !== "Temp"
)}
/>Example remote field linkage:
<Field fieldName="itemCode" onChange={["label", "itemTone"]} />
<Field
fieldName="itemCode"
onChange={{ update: ["label"], with: ["active"] }}
/>Example relation filter linkage:
<Field fieldName="companyId" />
<Field
fieldName="departmentId"
filters={[
["companyId", "=", "{{ companyId }}"],
"AND",
["active", "=", true],
"AND",
["effectiveDate", "<=", "TODAY"],
]}
/>Relation filter notes in ModelForm:
{{ companyId }}resolves from current form values before the relation query is sent (unified template syntax{{ expr }})- backend env tokens such as
TODAY,NOW,USER_ID,USER_COMP_IDare passed through unchanged; literals use{{ 'value' }}or backend tokens like{{ NOW }}as needed Field.filtersoverridesmetaField.filters; if omitted, metadata filters still apply- unresolved
{{ expr }}dependencies pause the relation query instead of loading unfiltered data
Examples of using widgetType to drive renderer behavior:
<Field
fieldName="startTime"
widgetType="HH:mm"
/>
<Field
fieldName="photo"
widgetType="Image"
/>
<Field
fieldName="gallery"
widgetType="MultiImage"
widgetProps={{ maxCount: 6, columns: 3, aspectRatio: "4 / 3", helperText: "Recommended 1200x900" }}
/>
<Field
fieldName="score"
widgetType="Slider"
widgetProps={{ minValue: 0, maxValue: 100, step: 5 }}
/>
<Field
fieldName="content"
widgetType="RichText"
/>
<Field
fieldName="notes"
widgetType="Markdown"
widgetProps={{ mode: "split", minHeight: 360 }}
/>
<Field
fieldName="script"
widgetType="Code"
widgetProps={{ language: "python", minHeight: 320, lineNumbers: true }}
/>
<Field
fieldName="startTime"
placeholder="Select start time"
/>File / MultiFile automatically use current ModelForm record id in edit mode.
Widget Props
Use placeholder for field-level input placeholder text.
Use widgetProps only for widget-specific configuration.
Scope note:
widgetPropsapplies toModelFormwidgets and table inline editors because those paths renderFielddirectlyModelTable/RelationTableread-mode cells intentionally do not consumewidgetProps; table image/file cells use the shared compact renderer described in ModelTable
Current supported examples:
<Field
fieldName="progress"
widgetType="Slider"
widgetProps={{ minValue: 0, maxValue: 10, step: 0.5 }}
/>
<Field
fieldName="avatar"
widgetType="Image"
widgetProps={{
aspectRatio: "1 / 1",
objectFit: "cover",
helperText: "Square image recommended",
crop: { enabled: true, aspect: 1, shape: "round" },
}}
/>
<Field
fieldName="photos"
widgetType="MultiImage"
widgetProps={{
maxCount: 8,
columns: 4,
aspectRatio: "16 / 9",
uploadText: "Upload gallery",
crop: { enabled: true, aspect: 16 / 9 },
}}
/>
<Field
fieldName="status"
widgetType="Radio"
required
/>
<Field
fieldName="script"
widgetType="Code"
widgetProps={{
language: "sql",
minHeight: 320,
maxHeight: 560,
lineNumbers: true,
lineWrapping: false,
tabSize: 2,
}}
/>
<Field
fieldName="config"
widgetProps={{
minHeight: 320,
maxHeight: 560,
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
formatOnBlur: true,
}}
/>JsonField now uses react-codemirror by default. Common JSON editor widgetProps:
height: fixed editor heightminHeight: minimum editor heightmaxHeight: maximum editor heightlineNumbers: show or hide gutter line numberslineWrapping: wrap long linestabSize: indentation sizeformatOnBlur: format valid JSON after blurautoFocus: focus editor on mount
CodeWidget supports these common widgetProps:
language:plain,java,html,json,markdown,python,sql,yaml,ymlheight: fixed editor heightminHeight: minimum editor heightmaxHeight: maximum editor heightlineNumbers: show or hide gutter line numberslineWrapping: wrap long linestabSize: indentation sizeautoFocus: focus editor on mount
MarkdownWidget supports these common widgetProps:
mode:split,edit,preview(default:split)height: fixed editor/preview heightminHeight: minimum editor/preview heightmaxHeight: maximum editor/preview heightlineNumbers: show or hide editor line numberslineWrapping: wrap long lines in editor modetabSize: indentation sizeautoFocus: focus editor on mount
MarkdownWidget uses react-markdown for preview and enables remark-gfm by default.
mode behavior:
split: show editor and preview side by side on desktop; stack vertically on smaller screensedit: show editor onlypreview: show preview only
Field Full Width
Field supports fullWidth for these field renderers:
StringField + PlainTextWidget(fieldType="String"+widgetType="PlainText")StringField + RichTextWidget(fieldType="String"+widgetType="RichText")StringField + MarkdownWidget(fieldType="String"+widgetType="Markdown")StringField + CodeWidget(fieldType="String"+widgetType="Code")OneToManyFieldManyToManyField
Default is fullWidth={true} for all fields above.
Set fullWidth={false} to render in normal grid width.
<Field fieldName="description" widgetType="PlainText" />
<Field fieldName="notes" widgetType="RichText" fullWidth={false} />
<Field fieldName="optionItems" fullWidth={false} />
<Field fieldName="userIds" fullWidth={false} />Field Label Visibility
Field supports hideLabel to control whether the entire field label block (FormLabelWithTooltip) is rendered.
- Default:
hideLabel={false}(show label) - Set
hideLabel={true}to hide the entire label block (label text + tooltip icon)
<Field fieldName="description" hideLabel={true} />ReadOnly vs Disabled
Use readOnly and disabled with different intent:
readOnly: user can view value clearly, and the field remains part of the normal detail-reading experience. Prefer this for detail pages, audit-style viewing, and fields that should stay easy to scan/copy.disabled: control is temporarily or structurally unavailable. Prefer this for permission restrictions, unmet prerequisites, async submitting/loading, workflow/state locks, or feature gating.
In HR SaaS forms, detail pages should generally prefer readOnly over disabled.
For widgetType="Code" and widgetType="Markdown", a read-only field whose value is empty shows the shared CodeEditorEmptyState hint instead of an empty CodeMirror (see src/components/fields/widgets/README.md).
XToMany Fields (Incremental Submit by Default)
ReferenceField now only handles:
ManyToOneOneToOne
OneToMany and ManyToMany are handled by dedicated field components internally and are still used through:
<Field fieldName="..." />OneToMany
- UI: local relation table in form body
- supports: add, edit, delete
- no
formView: row edit uses table-cell inline edit (click row to enter edit) - with
formView: row edit/create uses runtimeModelDialog - submit default: patch map (incremental)
Inline edit behavior (OneToMany, without formView):
- row enters edit mode only after row click (no auto-select on page enter)
- edited value is written directly to main form relation array and saved with parent
Save/Create - editable cells are limited to declared
<RelationTable><Field /></RelationTable>columns intersected with editable related-model fields - inline edit is available only in local table mode (
!isPagedor remote conditions not met) - row-level
required/readonlyconditions evaluate against the current relation row withscope="relation-table" - row-level
Field.onChangeremote linkage also runs inscope="relation-table"and only patches the current relation row RelationTable.pageSizeonly affects paged relation tables (isPaged)
Enable patterns:
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" />
<Field fieldName="active" />
</RelationTable>
);
}
function MultiSortTableView() {
return (
<RelationTable
orders={[
["sequence", "ASC"],
["itemCode", "DESC"],
]}
pageSize={20}
>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" />
</RelationTable>
);
}
// Enable table-cell inline edit (recommended for local relation editing)
<Field fieldName="optionItems" tableView={OptionItemsTableView} />
// Disable inline edit and use dialog editing
<Field
fieldName="optionItems"
tableView={OptionItemsTableView}
formView={OptionItemsFormView}
/>
// Paged relation table (pagination enabled; may switch to remote searchPage mode)
<Field fieldName="optionItems" tableView={OptionItemsTableView} isPaged />Submit payload shape:
{
"Create": [{ "name": "new row" }],
"Update": [{ "id": "101", "name": "changed" }],
"Delete": ["102", "103"]
}Create mode constraint:
- only
Createis allowed
Update mode:
Create/Update/Deleteare allowed
OneToMany view binding example:
import { Field, RelationTable } from "@/components/fields";
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="label" readonly={[["active", "=", false]]} />
<Field fieldName="active" />
</RelationTable>
);
}
function OptionItemsFormView() {
return (
<ModelDialog title="Option Item">
<FormBody enableAuditLog={false}>
<FormSection label="General" hideHeader>
<Field fieldName="itemCode" />
<Field fieldName="label" />
<Field fieldName="sequence" />
<Field fieldName="active" />
<Field fieldName="description" />
</FormSection>
</FormBody>
</ModelDialog>
);
}
export default function SysOptionSetFormPage() {
return (
<ModelForm modelName="SysOptionSet">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection>
<Field fieldName="optionSetCode" />
<Field fieldName="name" />
<Field fieldName="description" />
<Field fieldName="active" />
</FormSection>
<FormSection>
<Field
fieldName="optionItems"
tableView={OptionItemsTableView}
formView={OptionItemsFormView}
/>
</FormSection>
</FormBody>
</ModelForm>
);
}ManyToMany
- UI: local relation table in form body
- supports: add, delete
- add opens a related-model picker table dialog (search/sort/columns/pagination)
- optional
formViewcan mount a custom read-onlyModelDialogfor row detail - submit default: patch map (incremental)
Submit payload shape:
{
"Add": ["1", "2", "3"],
"Remove": ["4", "5"]
}Create mode constraint:
- only
Addis allowed
Update mode:
Add/Removeare allowed
ManyToMany view binding example:
import { Field, RelationTable } from "@/components/fields";
function UserRoleUserIdsTableView() {
return (
<RelationTable orders={["username", "ASC"]} pageSize={10}>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</RelationTable>
);
}
function UserRoleUserIdsFormView() {
return (
<ModelDialog title="User Detail">
<FormSection label="General" hideHeader>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</FormSection>
</ModelDialog>
);
}
export default function UserRoleFormPage() {
return (
<ModelForm modelName="UserRole">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="name" />
<Field fieldName="code" />
<Field fieldName="description" />
<Field fieldName="active" />
</FormSection>
<FormSection>
<Field
fieldName="userIds"
tableView={UserRoleUserIdsTableView}
formView={UserRoleUserIdsFormView}
/>
</FormSection>
</FormBody>
</ModelForm>
);
}Notes:
tableViewcontrols relation-table columns through a zero-prop view component that returns<RelationTable />with child<Field />declarations plus optionalRelationTable.orders/RelationTable.pageSize.RelationTable.orderssupports either a single tuple (["username", "ASC"]) or multiple tuples ([["username", "ASC"], ["email", "DESC"]]).- remote relation table and picker queries use the effective field filter (
Field.filters ?? metaField.filters), relation-scoped filters, and runtime search / column filters. isPaged(OneToMany/ManyToMany fields only):false(default): include relationsubQueryingetById; relation table does not paginate in UI and renders all local rows.true: relation table enables pagination UI; whenrecordId + relatedModel + scoped relation filterare ready, data is loaded byrelatedModel.searchPage(remote mode), otherwise paginated locally.
- relation table pageSize default is
50; page-size selector is shown only when pagination is enabled (isPaged=true). - ManyToMany picker dialog (
Add) is server-driven; search/sort/page changes triggersearchPagerequests. formViewis optional. InManyToMany, row-click opensModelDialogin read mode; add/remove still uses picker behavior.- unresolved
{{ expr }}dependencies pause remote relation queries and picker queries until the dependent parent form value exists
OneToOne (Owned Inline)
For owned OneToOne relationships (e.g. UserProfile → UserAccount), passing formView to a OneToOne field renders its related-model fields inline inside the parent form, rather than showing a reference selector.
- UI: inline
FormSection(s) inside the parent form body - supports: edit all declared sub-fields
- sub-fields register in the parent RHF instance as
{fieldName}.{subField}(e.g.userId.username) getByIdautomatically addssubQueries: { userId: { fields: [...] } }derived statically from theformViewJSX — no extra config needed- submit default: incremental (update sends
{ id, ...onlyChangedSubFields }; create sends full sub-object withoutid) - field conditions (
dependsOn,showWhen) insideformViewresolve against the sub-object scope and are correctly prefixed when watching parent form values ManyToOnefields do not supportformView; a dev-modeconsole.erroris shown if misused
Usage:
function UserAccountOneToOneView() {
return (
<FormSection label="Account">
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
<Field fieldName="policyId" />
</FormSection>
);
}
export default function UserProfileFormPage() {
return (
<ModelForm modelName="UserProfile">
<FormHeader />
<FormToolbar />
<FormBody>
<FormSection label="General" hideHeader>
<Field fieldName="fullName" />
<Field fieldName="birthDate" />
<Field fieldName="gender" />
</FormSection>
{/* OneToOne inline: renders UserAccount fields inside this form */}
<Field fieldName="userId" formView={UserAccountOneToOneView} />
</FormBody>
</ModelForm>
);
}Submit payload shape (update, only nickname changed):
{
"id": "...",
"userId": {
"id": "...",
"nickname": "Alice"
}
}Submit payload shape (create):
{
"userId": {
"username": "alice",
"nickname": "Alice",
"email": "alice@example.com",
"mobile": null,
"status": "ACTIVE",
"policyId": "1"
}
}When formView is not provided, OneToOne behaves identically to ManyToOne and renders a reference selector widget.
Compatibility
Backend still supports full submit for XToMany fields.
Frontend ModelForm defaults to incremental submit (PatchType map) to avoid full-list overwrite risk in paginated relation editing.
Page Structure
Recommended default layout:
- Header: title + description
- Sticky toolbar:
- left: built-in
FormEditStatus + FormPrimaryActions(+FormWorkflowActionswhenModelForm/ModelSideForm enableWorkflow=true) - right: business actions area (custom actions + built-in Duplicate/Delete + More Actions)
- left: built-in
- Body:
FormBodyrenders either stacked sections or true tabs, plus the built-in audit panel layout - Audit:
FormBody(enableAuditLog)controls audit panel; right on large screens and bottom on small screens
Props
ModelForm Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
modelName | string | Yes | - | Model name used to request metadata from API (/metadata/getMetaModel). |
id | string | null | No | Route params.id ("new" => null) | Optional override. |
schemaBuilder | (context) => ZodTypeAny | No | - | Runtime schema extender. Receives { metaModel, baseSchema } built from resolved metadata. |
readOnly | boolean | No | false | Force read-only mode. |
defaultValues | Record<string, unknown> | No | - | Extra default values merged into metadata defaults. Useful for injecting parent context such as relatedField values. |
copyFromId | string | null | No | - | New mode only: prefill the form with the copyable field values (getCopyableFields) of this source record (duplicate flow). Full-page forms normally use the ?copyFrom=<id> search param instead; embedded forms pass this prop. Explicit context (defaultValues, search params, related field) wins over copied values. |
enableWorkflow | boolean | No | false | Show workflow action group in toolbar left area (edit mode only). |
enableCreate | boolean | No | auto | Built-in Create New action switch. false disables. Omitted follows default behavior (read-only forms hide unless explicitly true). |
enableDuplicate | boolean | No | auto | Built-in duplicate action switch. false disables. Omitted follows default behavior (read-only forms hide unless explicitly true). |
enableDelete | boolean | No | auto | Built-in delete action switch. false disables. Omitted follows default behavior (read-only forms hide unless explicitly true). |
confirmDeleteMessage | string | No | Delete this {modelLabel}? This action cannot be undone. | Confirm text for built-in delete action. |
timeline | ModelFormTimelineConfig | No | - | Timeline-model behavior toggles (enableAddVersion / enableVersionPanel / versionSummaryFields, all optional). Only consulted when metaModel.timeline is true; see “Timeline Models” below. |
sliceId | string | null | No | ?sliceId= search param | Timeline models, edit mode only: load this specific version (slice) instead of the as-of row (searchList by sliceId across the timeline). Full-page forms normally use the ?sliceId=<x> search param (ignored in dialog mode); embedded forms pass the prop. |
children | ReactNode | Yes | - | Form page layout content (FormHeader/FormToolbar/FormBody). |
Runtime field conditions:
Field.required,Field.readonly,Field.hiddensupportboolean | FilterCondition | dependsOn(...).- Conditions are evaluated against current form values.
FilterConditionautomatically tracks both operand fields and local{{ fieldName }}references.- Function conditions must be wrapped with
dependsOn([...], evaluator); bare function conditions are not supported. hiddenfields are not rendered and their validation errors are suppressed.required={false}can relax metadatarequiredat runtime;readonly={false}can override metadata readonly.- The same runtime behavior is used by
ModelFormand dialog-based forms built onDialogForm.
Remote Field.onChange in ModelForm:
- request path is
POST /<modelName>/onChange/<fieldName> - request always sends current field
value; edit mode also sendsid withomitted: onlyid + valuewith: ["a", "b"]: sends only declared dependent fields in submit/API shapewith: "all": sends current form submit shape- top-level registered XToMany fields are serialized as relation patch payloads, not raw UI rows
- response
valuespatch only returned keys;nullclears a field - response
readonly/requiredoverride local effective state until reset, cancel, reload, or a later response - this remote linkage runtime is implemented for
ModelForm; it is not automatically available in standaloneDialogForm
FormHeader Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
title | string | No | metaModel.label (fallback pageTitle) | Optional override. |
description | string | No | metaModel.description | Optional override. |
extras | ReactNode | No | - | Extra header content rendered near title. For timeline models in edit mode, the header automatically renders a FormSliceBadge before extras: the loaded version’s effective range (Current · 2026-01-01 → ongoing, 9999-12-31 shown as ongoing) with a Current / Past / Future tone. It answers “which version am I looking at”; nothing renders for non-timeline models or in create mode. |
children | ReactNode | No | - | Display-mode content below description. Field children render as read-only values via FieldDisplayScope. Use Group for inline layout. |
FormHeader with display-mode children:
<FormHeader>
<Group separator="·">
<Field name="employeeCode" />
<Field name="departmentName" />
</Group>
</FormHeader>FormBody Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
sectionNav | boolean | No | false | Enables sidebar section nav. When true, nav renders when the section/tab has at least 2 sections. |
enableAuditLog | boolean | No | true | Toggle audit panel (only renders in edit mode). |
children | ReactNode | Yes | - | Content nodes. FormSection/Field nodes at root level render as shared content above tabs; root FormTab nodes activate tabs mode. FormTab cannot be nested inside another FormTab. |
FormBody infers layout mode from its root children. Any root FormTab activates tabs mode; FormSection and Field nodes placed outside FormTab are rendered above the tab strip as shared content visible across all tabs.
FormBody also includes a built-in content surface style by default: rounded-(--ui-card-radius) border border-border bg-card p-(--ui-card-padding). Use className to add extra styles or override defaults when needed.
FormTab Props
FormTab is the root content block for tabbed FormBody layouts. It can contain multiple FormSection blocks or direct content nodes.
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
label | string | Yes | - | Visible tab label. |
value | string | No | auto | Optional stable tab id; auto-derived from label. |
sectionNav | boolean | No | - | Overrides FormBody’s sectionNav for this tab only. Takes priority when defined. |
children | ReactNode | No | - | Tab panel content. FormSection remains recommended. |
FormSection Props
FormSection is the default content block inside FormBody. It provides section title/description rendering, a responsive field grid, local section actions, and section-nav registration.
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
label | string | No | - | Visible section label; also used as the section-nav anchor text. |
description | string | No | - | Optional helper text rendered under the section header. |
className | string | No | - | Extra wrapper class for the section container. |
columns | 1 | 2 | 3 | 4 | No | 2 | Responsive grid column count for section content on md+ layouts. |
hideHeader | boolean | No | false | Hides the visual section header, but the section can still participate in nav. |
divided | boolean | No | false | Adds a top border between sections. Suppressed on the first section (:first-child). |
children | ReactNode | No | - | Usually Field nodes plus optional section-scoped Action nodes. |
Notes:
FormSectionregisters itself to the nearestFormBodysection registry automatically.- Nav label falls back to
"Section"whenlabelis omitted. - Generic labels (
"Section") are auto-renamed in nav asSection 1,Section 2, and so on. hideHeaderonly affects the rendered header; it does not disable section-nav registration.dividedis most useful when sections have nolabel(i.e. the header itself is hidden) and visual separation is still needed. Whenlabelis present the heading already provides visual separation, sodividedis typically unnecessary.FormSectionsupports only local UI actions:type="link"andtype="custom"withplacement="header"orplacement="inline".
Section Nav
FormSectionNav is built into FormBody; pages usually do not render it directly.
Behavior:
FormBodycollects descendantFormSectionanchors and renders nav from their registration order.sectionNavisfalseby default; set totrueto enable the sidebar nav.- Nav renders only when the current view has at least 2 registered sections.
- In tabs mode,
FormTab’s ownsectionNavtakes priority overFormBody’s setting for that tab. Omitting it onFormTabinheritsFormBody’s value. - Clicking a nav item smoothly scrolls the form’s own scroll container, not the browser window.
- In stacked mode, sidebar nav is desktop-oriented: it appears from
xllayout when there is no right audit column, and from2xlwhen audit log is rendered on the right.
Stacked example:
<FormBody sectionNav>
<FormSection label="General" hideHeader>
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection label="Security">
<Field fieldName="passwordMinLength" />
<Field fieldName="passwordComplexityEnabled" />
</FormSection>
<FormSection label="Audit">
<Field fieldName="createdBy" readOnly />
<Field fieldName="createdDate" readOnly />
</FormSection>
<FormSection label="Advanced">
<Field fieldName="description" />
</FormSection>
</FormBody>Tabbed example:
import { FormBody, FormTab } from "@/components/views/form/components/FormBody";
<FormBody>
<FormTab label="Profile" sectionNav>
<FormSection label="General">
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection label="Advanced">
<Field fieldName="description" />
</FormSection>
</FormTab>
<FormTab label="Members">
<Field fieldName="userIds" />
</FormTab>
</FormBody>FormToolbar Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
children | ReactNode | No | - | Custom actions. Recommended: <Action type="..." />. |
className | string | No | - | Extra wrapper class for toolbar container. |
Actions In ModelForm
Common Action / BulkAction API now lives in Actions.
This section keeps only the ModelForm container rules and a complete page-level example.
Container support:
| Container | Supported Action Types | Supported Placements |
|---|---|---|
FormToolbar | default, dialog, link, custom | toolbar, more |
FormSection | link, custom | header, inline |
Rules:
FormToolbaris the action area for page-level business actionsFormSectionis a local UI action area and does not execute model API actions directly- for API actions (
default/dialog), place actions inFormToolbar - built-in workflow/create/duplicate/delete toolbar behavior is configured on
ModelForm/ModelSideFormprops - edit mode with unsaved changes: clicking business actions asks whether to discard changes before continuing
- create mode: built-in
Duplicate/Deleteremain visible but disabled - built-in
Duplicateonly appears when the model metadata iscopyable(MetaModel.copyable === true); non-copyable models never expose the action regardless ofenableDuplicate - built-in
Duplicatenever inserts directly: it loads the source record’s copyable values viagetCopyableFieldsand opens a prefilled new-mode form (full-page forms navigate tonew?copyFrom=<id>; ModelSideForm enters inline create mode with the prefill), so the user can adjust unique/business fields before saving through the normal create flow; which fields are copyable (decided by each field’sMetaField.copyable) is resolved by the backend
Complete example:
import { Action } from "@/components/actions/Action";
import { FormSection } from "@/components/views/form/components/FormSection";
import { Field } from "@/components/fields";
import { ActionDialog } from "@/components/views/dialogs";
import { FormBody } from "@/components/views/form/components/FormBody";
import { FormToolbar } from "@/components/views/form/components/FormToolbar";
import { ModelForm } from "@/components/views/form/ModelForm";
import { ExternalLink, Lock, RefreshCw, ShieldCheck } from "lucide-react";
function UnlockDialog() {
return (
<ActionDialog title="Unlock Account">
<Field fieldName="reason" label="Reason" widgetType="PlainText" />
</ActionDialog>
);
}
<ModelForm modelName="UserAccount">
<FormToolbar>
<Action
label="Lock"
operation="lockAccount"
placement="toolbar"
icon={Lock}
confirmMessage="Lock this account?"
/>
<Action
type="dialog"
label="Unlock"
operation="unlockAccount"
placement="more"
icon={ShieldCheck}
component={UnlockDialog}
/>
</FormToolbar>
<FormBody>
<FormSection label="Credentials">
<Action
type="link"
label="Open Docs"
placement="header"
icon={ExternalLink}
href="https://docs.example.com/credentials"
/>
<Action
type="custom"
label="Regenerate Preview"
placement="inline"
icon={RefreshCw}
onClick={() => console.log("regenerate")}
/>
<Field fieldName="username" />
<Field fieldName="status" />
</FormSection>
</FormBody>
</ModelForm>;Context API
Inside ModelForm children, use useModelFormContext() to access:
pageTitle,pageDescriptionisEditing,isSubmitting,effectiveReadOnlyform(react-hook-forminstance)onCancel()metaModel,id
Cascaded Field Path
<Field fieldName="lastActivityId.status" /> (dot-notation) reads a related record’s field and renders it read-only. The form plan walker collects every cascaded path declared in the body, calls POST /metadata/resolveCascadedPaths once to resolve all leaf metaFields, folds the matching SubQueries into getById, and exposes the resolutions to <Field> via CascadedResolutionsProvider.
<ModelForm modelName="AppEnv" recordId={envId}>
<Field fieldName="name" />
<Field fieldName="lastActivityId" /> {/* normal ManyToOne */}
<Field fieldName="lastActivityId.status" /> {/* cascaded — readonly */}
<Field fieldName="lastActivityId.finishedTime" /> {/* shares base, auto-merged */}
<Field fieldName="ownerId.departmentId.name" /> {/* depth-3 */}
</ModelForm>Notes:
- always read-only — never registers with RHF, never appears in
formState.dirtyFields - effective metadata (fieldType / widgetType / label / optionSetCode) comes from the leaf field;
props.label/props.widgetTypestill override - works inside
ModelSideFormautomatically (it composesModelForm) - nested cascaded paths inside
formViewcallbacks (depth > 0) are not yet resolved — devconsole.warnand ”-” placeholder
Full reference & semantics: Cascaded Field Path in the fields README.
Permission Integration
ModelForm auto-gates by modelName against the generated MODEL_PERMISSIONS lookup — business pages do not call usePermission for the standard toolbar controls or for any custom <Action permission="…"> child.
What gets gated for free, just from passing modelName:
| Built-in control | Action segment checked | Effect when denied |
|---|---|---|
| Toolbar “Save” / write | update | Form is forced into read-only mode (no Save button, fields frozen) |
| Toolbar “Edit” button | update | Hidden — enableUpdate resolves to false |
| Toolbar “Create New” | create | Hidden — enableCreate resolves to false |
| Toolbar “Duplicate” | create | Hidden — enableDuplicate resolves to false |
| Toolbar “Delete” | delete | Hidden — enableDelete resolves to false |
Custom <Action permission="…" /> children inside <FormToolbar> and <FormSection> are filtered before render — same semantics as ModelTable. Pass the manifest action segment (e.g. permission="transfer").
Status semantics match ModelTable:
- Granted → control / action stays visible.
- Denied → control / action hidden; form is forced read-only when
updateis denied (even if the URL is?mode=edit, the user lands in read mode — server enforces this independently, this is UX). - Unmanaged →
(modelName, action)not inMODEL_PERMISSIONS(ambiguous across pages). Auto-gate has no opinion; page can pass explicitreadOnly/enable*props instead.
Business props still win when set to false (page explicitly hides Edit even for SUPER_ADMIN). SUPER_ADMIN short-circuits permission lookups internally.
Example:
<ModelForm modelName="Employee">
<FormToolbar>
<Action
type="custom"
label="Transfer"
permission="transfer" {/* gated by Employee.transfer */}
onClick={openTransferDialog}
/>
</FormToolbar>
<FormSection label="Profile">
<Field fieldName="fullName" />
...
</FormSection>
</ModelForm>Save / Cancel / Edit / Delete are auto-shown / auto-hidden purely from modelName="Employee"; “Transfer” is auto-hidden when the user lacks Employee.transfer.
Built-in Behavior
- Create/edit mode defaults and reset handling.
- Reset behavior is snapshot-guarded:
- record/model identity change => reset
- pristine form + remote snapshot changed => reset
- dirty form + background refetch => do not overwrite current edits
- Metadata resolution policy: always fetch from
/metadata/getMetaModel; first response is cached by React Query and reused. - Metadata-driven field props are resolved by the internal field runtime; business code should stay on
Field. - Cancel / Back behavior (split by mode and placement):
- edit / create mode:
Cancelbutton lives in the toolbar next toSave(FormPrimaryActions); confirms when dirty, resets to the latest loaded snapshot, returns to read-only mode - read mode:
Backbutton lives in the page header right side (FormBackButton, hidden insideModelSideForm); navigates to the route derived from the breadcrumb (resolveBackRoute= navigation manifest + pathname). MultiViewlinkTodetail routes (/list/<tab>/<id>) resolve to the tab crumb/list?tab=<tab>instead of the route-less/list/<tab>, so Back never 404s and lands on the originating tab — no carried query state, refresh / deep-link resolve identically
- edit / create mode:
- Save/create mutation handling with toasts.
- Audit query is built in via
useGetChangeLogQuery(modelName, id)with:pageNumber=1pageSize=50order=DESCincludeCreation=truedataMask=true
- Global audit API switch:
configs.env.enableChangeLog(NEXT_PUBLIC_ENABLE_CHANGE_LOG, defaulttrue)- when disabled,
FormAuditPaneldoes not issue change-log API requests and shows a disabled hint text
FormWorkflowActions+WorkflowActionGroupsupports workflow states:draft: submitpending: withdraw/approve/rejectapproved: withdraw approvalrejected: resubmit
- Workflow actions are disabled while form is dirty or submitting.
- Audit event rendering rules:
update:<=5expanded,>5show first 5 +Show all fields (N)create: collapsed by defaultdelete: operation info only
Timeline Models
Active only when metaModel.timeline is true; non-timeline models ignore everything in this section.
- Slice badge: in edit mode,
FormHeaderautomatically renders the loaded version’s effective range with a Current / Past / Future tone (Current · 2026-01-01 → ongoing;9999-12-31renders asongoing). Nothing renders in create mode. - Version list data:
useVersionListQuery(modelName, id)fetches ALL slices of one entity (acrossTimeline: true, newest effective range first). Every form-owned caller narrows the query to the structural keys + summary fields viabuildVersionListFields, so the panel, the collision check and the delete-confirm count share ONE cache entry. Its query key follows the[modelName, ...]convention, soinvalidateModelQueriesrefreshes it after addVersion / update / deleteBySliceId mutations. timelineprop (ModelFormTimelineConfig):enableAddVersion/enableVersionPanel/versionSummaryFields— toggles for the built-in Add Version action and the Versions panel.- Add Version action: a built-in toolbar action on persisted timeline records; in create mode it renders disabled with a hint (same convention as Duplicate / Delete). Gate:
metaModel.timelineAND create permission (server-sideaddVersionruns the create pipeline, so a user may add versions without update permission) ANDtimeline.enableAddVersion !== false. Entering the mode discards pending edits first (confirm dialog when dirty) so the form starts from a clean baseline. - Add-version mode: the toolbar shows a required “Effective from” date (defaults to today) with a live hint — “The new version is created exactly from the values shown on this form”, switching to “A version already starts on this date — submitting corrects that version instead” on a same-start collision (dates come from
useVersionListQuery, loaded only while the mode is active). Fields unlock even when entered from route read-mode. Record-level actions (Create New / Duplicate / Delete) hide while the mode is active; the submit button readsAdd Versionand stays disabled until an effective date is set. - Add-version submit: the FULL form row —
id+effectiveStartDate+ every displayed field — viaaddVersionAndFetch. WYSIWYG: the new version is exactly what the user sees, baselined on the loaded version (submitting with zero edits creates a copy of it). The API itself also accepts partial rows (absent fields are copied forward from the adjacent slice server-side) — that remains the integration-caller affordance; the form deliberately does not use it, so what is on screen never diverges from what is saved. XToMany relation patches are excluded in this mode — they are keyed to the source slice’s rows. On success the form returns to read mode as-of today; opening the new version directly lands with the Versions panel. - Cancel in add-version mode discards the draft and exits the mode without navigating.
- Versions panel: on timeline models with a persisted record,
FormBodyrenders aVersionsside panel above the audit log (same right column / bottom stack). Each row shows the effective range with a Current / Past / Future badge and a summary (timeline.versionSummaryFields, defaulting to the modeldisplayName); the loaded version is highlighted. Row actions:Open/Correctnavigate via the?sliceId=search param (read / edit mode; hidden inside side-form shells, which have no slice routing),Deleteremoves the version after a destructive confirm.CorrectandDeletefollow the toolbar permission convention (update / delete status; hidden when denied, shown for unmanaged models). Deleting the ONLY version deletes the entity itself (a timeline entity IS its slices), so the confirm escalates to record-level wording and the call routes through entity deletion —onDeleteFK strategies apply (inside side-form shells the sole-version delete stays disabled: no list navigation target). Expanding a row loads that version’s slice-level change log (getSliceChangeLog). Rows are rendered newest first; non-contiguous versions get a dashedNo coverage: <start> → <end>separator between them — gaps are legal on the backend (as-of reads inside one return nothing) and the separator makes them visible instead of silently implied. Disable the panel withtimeline.enableVersionPanel: false. - Terminate / Reopen: the tail (newest) version row carries a
Terminate…action — a dialog asking for the end date (defaults to today, must not precede the tail version’s start; to end earlier, delete trailing versions first and the end date transfers backward per the backend heal rule). Confirming callssetEndDate(id, endDate). A terminated tail shows a destructiveTerminatedchip and the action flips toReopen, which confirms and callssetEndDate(id, "9999-12-31")— the record becomes open-ended again. Both are gated on the update permission status (same convention asCorrect) and stay available in embedded hosts (they mutate without navigating). Note the dialog’s past-date warning: terminating with a date before today immediately hides the record from every as-of list and its detail page — it stays reachable only through the Versions panel’sOpenlinks (?sliceId=), which is also where you reopen it. - Loading a specific version: with
sliceId(prop or search param) the form loads that slice across the timeline instead of the as-of row — the badge shows its real range, edit mode corrects exactly that version (updatekeyed bysliceId), and Add Version branches from it. - Entity deletion: the built-in Delete confirm on timeline models states the blast radius — “All N versions will be removed” (N from the Versions panel’s cached query).
Page Navigation (Back + Prev/Next in Header)
The page header right side hosts two navigation primitives that operate at the page/record level (not the form-data level), keeping FormToolbar focused on form lifecycle actions:
FormBackButton(read-only mode only) — returns to the breadcrumb-derived route (resolveBackRoute): the registered list page for normal detail, or the tab crumb/list?tab=<tab>for MultiViewlinkTodetail. Purely path/manifest derived (no carried query state). Hidden inModelSideFormand in edit/create mode (whereCancelin the toolbar handles the equivalent intent).FormSiblingNav—‹ index/total ›buttons that jump to the previous/next sibling record.
Sibling navigation requires that the user arrived via a row/card click in a sibling list view (ModelTable / ModelBoard / ModelCard) of the same model:
- Source of truth is a module-level snapshot at
src/components/views/form/hooks/siblingNavStore.ts. The list view writes the currently visible (sorted, server-paged) ids into the snapshot at click time;FormSiblingNavreads it on render. - Scope per view:
ModelTable: ids of the current page (server-side filter + sort applied).ModelBoard: ids within the column the clicked card belongs to.ModelCard: ids of the current page.
- Buttons are hidden when there is no snapshot (e.g. direct URL, page reload, or a different model). They are disabled at the first/last record, while submitting, and while the form is dirty (so unsaved edits never get lost on click).
- The snapshot is intentionally ephemeral: in-memory only, not persisted to URL, sessionStorage, or React state. It survives client-side route changes (
router.push) but resets on full reload.
Dialog Architecture
Detailed dialog API, props, and full examples are maintained in: Dialogs.
Quick selection:
ActionDialog: invoke model operation/{modelName}/{operation}(single/bulk).ModelDialog: relation-field runtime dialog, no explicitmodelNameneeded.
To avoid documentation drift, this file only keeps form-page guidance; dialog details are centralized in dialogs README.