Skip to Content

ModelSideForm

Split-pane view: a side panel on the left selects a record, and a ModelForm on the right displays/edits it.

Quick Start

import { Field } from "@/components/fields"; import { FormBody } from "@/components/views/form/components/FormBody"; import { FormHeader } from "@/components/views/form/components/FormHeader"; import { FormSection } from "@/components/views/form/components/FormSection"; import { FormToolbar } from "@/components/views/form/components/FormToolbar"; import { SideTree } from "@/components/views/shared/side-panel/SideTree"; import { ModelSideForm } from "@/components/views/side-form/ModelSideForm"; export default function SettingPage() { return ( <ModelSideForm modelName="Setting"> {/* Left: side panel for record selection */} <SideTree modelName="SettingGroup" filterField="groupId" labelField="name" parentField="parentId" /> {/* Right: standard ModelForm content */} <FormHeader /> <FormToolbar /> <FormBody> <FormSection labelName="General"> <Field fieldName="key" /> <Field fieldName="value" /> </FormSection> </FormBody> </ModelSideForm> ); }

How It Works

  1. Children are split into one side panel element (SideTree, SideCard, or SideList) and form content (everything else).
  2. The side panel is wrapped in SidePanelContainerProvider so its selection events flow back to ModelSideForm.
  3. When a record is selected, a ModelForm is mounted with that record’s id. The form supports full read/edit lifecycle — the same ModelForm used in standalone [id]/page.tsx routes.
  4. Switching records re-mounts the form (via a key change), giving each record a fresh form state.
  5. If the form has unsaved changes, a confirmation dialog asks whether to discard before switching.
  6. When no record is selected, a placeholder message is shown.

Props

PropTypeRequiredDefaultNotes
modelNamestringYes-The model to load into ModelForm.
childrenReactNodeYes-One side panel element + standard form components.

Children Structure

Children are split into two groups:

GroupComponentsRenders at
Side panelOne of: SideTree, SideCard, SideListLeft panel (280px)
Form contentFormHeader, FormToolbar, FormBody, Field, etc.Right panel (flex-1)

The form content children are passed directly to ModelForm — use the same component composition as a standalone ModelForm page.

Layout

SideFormLayout renders a two-column layout:

┌──────────────┬──────────────────────────────────┐ │ Side Panel │ ModelForm │ │ (280px) │ (flex-1) │ │ │ │ │ SideTree / │ FormHeader │ │ SideCard / │ FormToolbar │ │ SideList │ FormBody │ │ │ FormSection │ │ │ Field ... │ │ │ │ └──────────────┴──────────────────────────────────┘
  • Side panel: fixed 280px, border-r divider
  • Form area: flex-1, scrolls independently
  • Both panels fill the full available height

Side Panel Options

Any side panel component can be used. The component determines the record selection UI:

ComponentBest ForSelection Mode
<SideTree>Hierarchical data (departments, categories)Tree node
<SideCard>Rich card display with header/body/footerCard click
<SideList>Simple list with Field-based row templatesList item

SideTree Example

<ModelSideForm modelName="SysField"> <SideTree title="System Model" modelName="SysModel" filterField="modelId" labelField="labelName" parentField="parentId" sortField="modelName" selectionMode="single" defaultExpandedLevel={2} /> <FormHeader /> <FormBody> <FormSection labelName="Field Info"> <Field fieldName="fieldName" /> <Field fieldName="labelName" /> <Field fieldName="fieldType" /> </FormSection> </FormBody> </ModelSideForm>

SideList Example

<ModelSideForm modelName="DesignWorkItem"> <SideList modelName="DesignWorkItem" filterField="id" filters={[["status", "=", "IN_PROGRESS"], "OR", ["status", "=", "READY"]]} searchable > <WorkItemListItem /> </SideList> <FormHeader /> <FormToolbar /> <FormBody> <FormSection labelName="General"> <Field fieldName="name" /> <Field fieldName="status" /> </FormSection> </FormBody> </ModelSideForm>

SideList children define the row template. Each row is wrapped in RecordContextProvider, so custom components can use useRecordContext():

import { useRecordContext } from "@/components/contexts/RecordContext"; import { Badge } from "@/components/ui/badge"; function WorkItemListItem() { const { record } = useRecordContext(); return ( <div className="flex w-full items-center justify-between gap-2"> <span className="truncate text-xs">{record.name as string}</span> <Badge variant="outline" className="shrink-0 text-[10px]"> {record.status as string} </Badge> </div> ); }

SideCard Example

<ModelSideForm modelName="DesignWorkItem"> <SideCard modelName="DesignApp" filterField="appId" sortField="appName" searchable > <SideCard.Header> <Field fieldName="appName" /> </SideCard.Header> <Field fieldName="appCode" /> <SideCard.Footer> <Field fieldName="updatedTime" /> </SideCard.Footer> </SideCard> <FormHeader /> <FormBody> <FormSection labelName="General"> <Field fieldName="name" /> <Field fieldName="description" /> </FormSection> </FormBody> </ModelSideForm>

Form Content

The right side renders a full ModelForm. Use the same form component composition as standalone form pages:

ComponentPurpose
FormHeaderTitle bar with model label and description
FormToolbarBusiness actions (Save, Delete, custom)
FormBodyForm body with tabs and sections
FormSectionGrid layout section with label
FieldIndividual form field
ActionCustom toolbar or form actions

With Toolbar Actions

import { Action, dependsOn } from "@/components/actions/Action"; import { CheckCircle, XCircle } from "lucide-react"; <ModelSideForm modelName="DesignWorkItem"> <SideList filterField="id" searchable> <WorkItemListItem /> </SideList> <FormHeader /> <FormToolbar> <Action type="default" labelName="Approve" icon={CheckCircle} operation="approve" placement="toolbar" confirmMessage="Approve this item?" successMessage="Approved." disabled={dependsOn(["id"], ({ mode }) => mode === "create")} hidden={["status", "!=", "PENDING"]} /> <Action type="default" labelName="Reject" icon={XCircle} operation="reject" placement="more" confirmMessage="Reject this item?" successMessage="Rejected." disabled={dependsOn(["id"], ({ mode }) => mode === "create")} hidden={["status", "!=", "PENDING"]} /> </FormToolbar> <FormBody> <FormSection labelName="General"> <Field fieldName="name" /> <Field fieldName="status" /> <Field fieldName="description" /> </FormSection> </FormBody> </ModelSideForm>

With Multiple FormSections

<ModelSideForm modelName="Employee"> <SideTree modelName="Department" filterField="departmentId" labelField="name" parentField="parentId" /> <FormHeader /> <FormToolbar /> <FormBody> <FormSection labelName="Basic Info"> <Field fieldName="firstName" /> <Field fieldName="lastName" /> <Field fieldName="email" /> </FormSection> <FormSection labelName="Employment"> <Field fieldName="departmentId" /> <Field fieldName="positionId" /> <Field fieldName="hireDate" /> </FormSection> <FormSection labelName="Custom Content"> <MyCustomComponent /> </FormSection> </FormBody> </ModelSideForm>

Dirty State & Record Switching

ModelSideForm automatically tracks whether the form has unsaved changes. When you click a different record in the side panel while the form is dirty:

  1. A confirmation dialog appears: “You have unsaved changes. Discard them and switch to the selected record?”
  2. Discard → switches to the new record, old changes are lost
  3. Keep editing → stays on the current record, side panel selection is not changed

This prevents accidental data loss. The form is fully re-mounted on each record switch (via React key), so each record gets a clean form state.

Comparison with Other Views

FeatureModelSideFormModelTableModelCard
Data displaySingle record formMulti-row table gridMulti-card grid
Side panelRequired (selection)Optional (filtering)Optional (filtering)
Record editingFull form editOptional inline edit-
Click behaviorPanel selects recordNavigate or inline editNavigate
Dirty state guardYesInline edit only-
Search/Filter/SortSide panel onlyFull toolbarSimplified toolbar
PaginationSide panel (client)Server-sideServer-side
Last updated on