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/common/form-section";
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
labelName="Lock Account"
operation="lockAccount"
placement="more"
confirmMessage="Lock this user account?"
successMessage="User account locked."
errorMessage="Failed to lock user account."
/>
<Action
type="dialog"
labelName="Unlock Account"
operation="unlockAccount"
placement="more"
successMessage="User account unlocked."
errorMessage="Failed to unlock user account."
component={UserAccountUnlockActionDialog}
/>
</FormToolbar>
<FormBody>
<FormSection labelName="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"
labelName="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 labelName="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"
labelName="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="itemColor" hidden={["active", "=", false]} />
<Field
fieldName="description"
readonly={[
["status", "IN", ["approved", "archived"]],
"OR",
[["type", "=", "SYSTEM"], "AND", ["editable", "!=", true]],
]}
/>
<Field
fieldName="itemName"
required={dependsOn(["active", "itemCode"], ({ values, isEditing }) =>
!isEditing && values.active === true && values.itemCode !== "Temp"
)}
/>Example remote field linkage:
<Field fieldName="itemCode" onChange={["itemName", "itemColor"]} />
<Field
fieldName="itemCode"
onChange={{ update: ["itemName"], 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 + TextWidget(fieldType="String"+widgetType="Text")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="Text" />
<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.
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:
const optionItemsTableView = (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
<Field fieldName="active" />
</RelationTable>
);
const multiSortTableView = (
<RelationTable
orders={[
["sequence", "ASC"],
["itemCode", "DESC"],
]}
pageSize={20}
>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
</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";
const optionItemsTableView = (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="itemName" readonly={[["active", "=", false]]} />
<Field fieldName="active" />
</RelationTable>
);
function OptionItemsFormView() {
return (
<ModelDialog title="Option Item">
<FormBody enableAuditLog={false}>
<FormSection labelName="General" hideHeader>
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
<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";
const userRoleUserIdsTableView = (
<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 labelName="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 labelName="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 child<Field />declarations and 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 labelName="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 labelName="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": "[email protected]",
"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(+FormWorkflowActionswhenenableWorkflow=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. |
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.labelName (fallback pageTitle) | Optional override. |
description | string | No | metaModel.description | Optional override. |
extras | ReactNode | No | - | Extra header content rendered near title. |
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 |
|---|---|---|---|---|
labelName | 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 |
|---|---|---|---|---|
labelName | 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"whenlabelNameis 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 nolabelName(i.e. the header itself is hidden) and visual separation is still needed. WhenlabelNameis 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 labelName="General" hideHeader>
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection labelName="Security">
<Field fieldName="passwordMinLength" />
<Field fieldName="passwordComplexityEnabled" />
</FormSection>
<FormSection labelName="Audit">
<Field fieldName="createdBy" readOnly />
<Field fieldName="createdDate" readOnly />
</FormSection>
<FormSection labelName="Advanced">
<Field fieldName="description" />
</FormSection>
</FormBody>Tabbed example:
import { FormBody, FormTab } from "@/components/views/form/components/FormBody";
<FormBody>
<FormTab labelName="Profile" sectionNav>
<FormSection labelName="General">
<Field fieldName="name" />
<Field fieldName="code" />
</FormSection>
<FormSection labelName="Advanced">
<Field fieldName="description" />
</FormSection>
</FormTab>
<FormTab labelName="Members">
<Field fieldName="userIds" />
</FormTab>
</FormBody>FormToolbar Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
children | ReactNode | No | - | Custom actions. Recommended: <Action type=\"...\" />. |
enableWorkflow | boolean | No | false | Toggle workflow action group in toolbar left area. Only shown in edit mode and not read-only. |
enableCreate | boolean | No | true | Built-in Create New action in the right toolbar group. Explicit prop value wins; when omitted, hard read-only forms hide it by default. |
enableDuplicate | boolean | No | true | Built-in duplicate action. Explicit prop value wins; when omitted, hard read-only forms hide it by default. In create state it stays visible but disabled. |
enableDelete | boolean | No | true | Built-in delete action. Explicit prop value wins; when omitted, hard read-only forms hide it by default. In create state it stays visible but disabled. |
confirmDeleteMessage | string | No | Delete this {modelLabel}? This action cannot be undone. | Confirm text for built-in delete action. |
Actions In ModelForm
Common Action / BulkAction API now lives in Action.
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 - 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
Duplicatestill uses backendcopyById; exclusion ofBaseModel.reversedFieldsis handled by backend duplicate semantics
Complete example:
import { Action } from "@/components/actions/Action";
import { FormSection } from "@/components/common/form-section";
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" labelName="Reason" widgetType="Text" />
</ActionDialog>
);
}
<ModelForm modelName="UserAccount">
<FormToolbar>
<Action
labelName="Lock"
operation="lockAccount"
placement="toolbar"
icon={Lock}
confirmMessage="Lock this account?"
/>
<Action
type="dialog"
labelName="Unlock"
operation="unlockAccount"
placement="more"
icon={ShieldCheck}
component={UnlockDialog}
/>
</FormToolbar>
<FormBody>
<FormSection labelName="Credentials">
<Action
type="link"
labelName="Open Docs"
placement="header"
icon={ExternalLink}
href="https://docs.example.com/credentials"
/>
<Action
type="custom"
labelName="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
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 behavior:
- edit mode:
Cancelconfirms (when dirty), resets form to latest loaded data, then switches to read-only mode - read mode:
Backnavigates to list page
- edit 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
Dialog Architecture
Detailed dialog API, props, and full examples are maintained in: Dialog.
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.