ModelTable
Composable data table view with:
- metadata-driven columns
- server-side query integration
- toolbar filter/sort/group controls
- optional side tree filter panel
Related Docs
- ModelCard — card grid view (shared toolbar dialogs, side panel, and data hooks)
- Dialogs
- ModelForm
- Actions
Quick Start
import { UserAccountUnlockActionDialog } from "@/app/user/user-account/components/user-account-unlock-action-dialog";
import { Action } from "@/components/actions/Action";
import { Field } from "@/components/fields";
import { ModelTable } from "@/components/views/table/ModelTable";
export default function UserAccountPage() {
return (
<ModelTable
modelName="UserAccount"
orders={["createdTime", "DESC"]}
>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
<Field fieldName="createdTime" />
<Action
labelName="Lock Account"
operation="lockAccount"
placement="more"
confirmMessage="Lock this user account?"
successMessage="User account locked."
/>
<Action
type="dialog"
labelName="Unlock Account"
operation="unlockAccount"
placement="more"
successMessage="User account unlocked."
component={UserAccountUnlockActionDialog}
/>
</ModelTable>
);
}Most pages do not need explicit generic parameters. ModelTable defaults row type to:
type ModelTableRowData = { id: string };Column Declaration
ModelTable is JSX-first:
- columns come from ordered
<Field />children - top-level query
fieldsare generated automatically from those declarations - top-level
ordersis the recommended way to declare default sorting initialParamsis the advanced query escape hatch for non-column query params such asfilters,pageSize,groupBy,effectiveDatechildrencan mix<Field />,<Action />, and<BulkAction />- at least one visible
<Field />declaration is required
Example:
<ModelTable
modelName="SysOptionSet"
orders={["optionSetCode", "ASC"]}
>
<Field fieldName="optionSetCode" readonly />
<Field fieldName="name" />
<Field fieldName="description" />
<Field fieldName="active" widgetType="CheckBox" />
</ModelTable>Preferred sorting syntax:
<ModelTable modelName="UserAccount" orders={["createdTime", "DESC"]}>
<Field fieldName="username" />
<Field fieldName="email" />
</ModelTable>Multi-sort syntax:
<ModelTable
modelName="SysField"
orders={[
["modelName", "ASC"],
["fieldName", "ASC"],
]}
>
<Field fieldName="modelName" />
<Field fieldName="fieldName" />
</ModelTable>Table declaration notes:
Fieldorder is the rendered column orderwidgetType,labelName,filters,onChange, and staticrequired/readonlyoverrides are reused by both read cells and inline editorsdefaultValueis create-only; in table flows it is used for relation row creation and inline editors, not for read-mode cells- inline-edit field values use the same UI value contracts as forms; for example
File -> FileInfo | null,MultiFile -> FileInfo[], andJSON/DTO/Filters/Ordersstay structured - table read cells intentionally do not consume
widgetProps; v1 uses a unified compact table renderer instead of form-style widget variants - for relation columns (
ManyToOne/OneToOne) in inline edit,filtersmay use{{ fieldName }}and resolves against the current editing row 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 hiddenonly supportsbooleanin table declarations;hidden={true}removes the whole column- conditional
required/readonlyare supported in inline edit, but conditionalhiddenis not
Detailed field value contracts are documented in Field & Widget.
Cascaded columns (related fields)
A column can pull a value from a related (ManyToOne / OneToOne) record using dot-notation in fieldName:
<ModelTable modelName="AppEnv">
<Field fieldName="name" />
<Field fieldName="lastDeploymentId.deployStatus" widgetType="StatusIcon" />
<Field fieldName="ownerId.email" />
</ModelTable>The list query controller folds the matching SubQuery into searchPage automatically — no need to hand-roll initialParams.subQueries for the displayed paths. Cascaded columns are always read-only (no inline-edit support) and use the leaf field’s metadata (fieldType / widgetType / labelName) for rendering. Hand-rolled initialParams.subQueries still merges with the auto-collected ones for advanced cases.
Full reference & semantics: Cascaded Field Path in the fields README.
File And Image Columns
Table-side file rendering is driven by API values, not by form widget state:
FileexpectsFileInfoMultiFileexpectsFileInfo[]- image preview uses
FileInfo.url - file link label falls back as
fileName -> fileId -> "-"
Read-mode behavior:
File+widgetType="Image"renders a compact thumbnail only; clicking it opens an image preview dialogMultiFile+widgetType="MultiImage"renders a compact thumbnail summary with+N; clicking it opens a gallery-style preview dialog- plain
Filerenders a downloadable filename link toFileInfo.url - plain
MultiFilerenders the first filename link plus+N - if an image item has no
url, the cell renders a compact placeholder box instead of a broken image - read-mode cells remain single-line / no-wrap; table rows are not globally expanded for multi-file content
These same compact read renderers are also used by relation tables (RelationTable) in read mode.
XToMany Read Cells
OneToMany and ManyToMany table cells use a compact tag-list renderer in read mode.
Read-mode behavior:
- values are treated as relation-like arrays, typically
ModelReference[] - each item renders as a compact tag using
displayName, thenidas fallback - if no item can produce a label, the cell falls back to a count summary such as
3 items - this applies to both
ModelTableandRelationTableread cells widgetType="TagList"does not change the read cell renderer; it only enables the searchable multi-select editor forManyToManyin forms and inline edit
Example:
<ModelTable modelName="UserRole">
<Field fieldName="name" />
<Field fieldName="userIds" widgetType="TagList" />
</ModelTable>Inline Edit
ModelTable supports optional row-level inline editing.
<ModelTable
modelName="TenantOptionItem"
inlineEdit
orders={["sequence", "ASC"]}
>
<Field fieldName="sequence" readonly />
<Field fieldName="companyId" />
<Field
fieldName="departmentId"
filters={[["companyId", "=", "{{ companyId }}"]]}
/>
<Field fieldName="itemName" readonly={[["active", "=", false]]} />
<Field fieldName="active" />
</ModelTable>dependsOn() example:
import { dependsOn, Field } from "@/components/fields";
<Field
fieldName="itemName"
required={dependsOn(
["active"],
({ values, scope, rowId }) =>
scope === "model-table" && Boolean(rowId) && values.active === true,
)}
/>;Behavior:
- default is
inlineEdit={false} false: row click navigates to detail page in read modetrue: row click activates inline edit for that row- editable cells render
Fielddirectly inside the table cell - active row shows row-level
Save/Cancel Savestays disabled until the active row has actual changesSavesubmits only changed editable fields for that row via update APICancelrestores the row from the latest loaded server snapshot- switching to another row while current row is dirty asks for discard confirmation
required/readonlysupportboolean,FilterCondition, anddependsOn([...], evaluator)- inline-edit conditions are evaluated against the current row object with
scope="model-table", plusrowIndexandrowId - relation-field filters using
{{ fieldName }}are also evaluated against the current row object - if a relation-field filter dependency is missing, that row’s relation query stays disabled instead of loading unfiltered options
- only metadata-editable and not effectively readonly columns become inline editors; unsupported columns stay read-only
File,MultiFile,Image, andMultiImageparticipate in inline edit and reuse the normalFieldupload widgets inside the active rowOneToManystays read-only in table inline editManyToManyparticipates in table inline edit only whenwidgetType="TagList"; otherwise it stays read-only- active edit rows may grow vertically for file/image widgets; non-active rows remain fixed-height
Remote Field.onChange
Inline edit also supports remote field linkage on declared columns:
<ModelTable modelName="SysOptionSet" inlineEdit>
<Field fieldName="optionSetCode" onChange={["name", "description"]} />
<Field fieldName="name" />
<Field fieldName="description" />
</ModelTable>Behavior in ModelTable inline edit:
- scope is the current editing row only
- request path is
POST /<modelName>/onChange/<fieldName> with: "all"serializes the current row, not the whole table- response
valuespatch only the current row - response
readonly/requiredapply only to the current row and override local effective state - remote rule state is cleared when the row is saved, cancelled, reloaded, or when editing switches to another row
Tab filters
ModelTable itself does not have a tabs prop. For tab-based filter switching
(or mixed view kinds like Board + Table under one header), wrap the table in
<MultiView> — see MultiView.
Developer Types
ModelTableRowWith<TExtra> is useful when you want strong row typing:
type UserAccountRow = ModelTableRowWith<{
username: string;
status: string;
locked: boolean;
}>;Side Panel (Optional)
ModelTable supports a left filter panel via <SideTree> / <SideCard> /
<SideList> children. Selection AND-merges into the table query.
<ModelTable modelName="SysField">
<SideTree
modelName="SysModel"
filterField="modelId"
labelField="labelName"
parentField="parentId"
/>
<Field fieldName="modelName" />
...
</ModelTable>For full reference (props, slots, custom panels), see Side Panel.
Column Header Filter
Each column header exposes a filter popover (the funnel icon) that produces a ColumnFilterValue. Column filters feed into the same merge pipeline as the top-level filters, workspace, side panel, and toolbar filters — see Filter precedence.
Available operators are derived from each column’s fieldType. The single source of truth is src/components/views/table/utils/filter-operators.ts. Unary operators (IS SET / IS NOT SET) require no value.
Date / DateTime columns additionally expose quick range presets (Today, Last N days, Next N days, This week / month / year, etc.) and one-click Is set / Is not set entries inside the same popover. See Date And Time Widgets → Quick range filter for the preset registry, interaction rules, time-zone handling, and persistence semantics.
Unified Active Toolbar State
Toolbar active state area can show and clear:
- Tree filter tag
- Column filter tags
- Condition filter preview
- Sort summary
- Group summary
Clear all clears all active toolbar states together.
Core Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
modelName | string | Yes | - | Used to fetch metadata API. |
labelName | string | No | - | Overrides the page title shown in the table header. Defaults to metaModel.labelName when omitted. |
description | string | No | - | Overrides the subtitle shown in the table header. Defaults to metaModel.description when omitted. |
inlineEdit | boolean | No | false | Enable row-click inline edit mode. When enabled, active-row editable cells render Field components instead of navigating to detail. |
orders | OrderCondition | No | - | Recommended default sort. Supports a single tuple (["createdTime", "DESC"]) or multiple tuples. Wins over initialParams.orders and MultiView.Tab.orders (context). |
filters | FilterCondition | No | - | Recommended base filter. Wins over initialParams.filters and MultiView.Tab.filters (context). AND-merged with workspace, search, column, side-panel, and toolbar filters at runtime. See precedence rules. |
initialParams | QueryParamsWithoutFields | No | - | Advanced initial query settings (pageSize, groupBy, effectiveDate, subQueries, splitBy, aggFunctions, summary). For filters / orders, prefer the top-level props. |
children | ReactNode | No | - | Ordered <Field /> declarations plus optional <Action />, <BulkAction />, and one side panel (<SideTree>, <SideCard>, or <SideList>). At least one visible <Field /> is required at runtime. |
enableBulkDelete | boolean | No | true | Enable built-in bulk delete entry. |
enableCreate | boolean | No | true | Enable built-in create button. |
enableImport | boolean | No | true | Enable built-in import dialog entry in More menu. |
enableExport | boolean | No | true | Enable built-in export dialog entry in More menu. |
bulkEditFields | string[] | No | - | Optional bulk-edit allowlist. If omitted, built-in Bulk Edit uses all metadata fields. |
excludeFields | string[] | No | - | Optional bulk-edit denylist. Always excluded from built-in Bulk Edit (in addition to reserved fields). |
linkTo | string | No | - | Subdirectory name (single segment) for row click navigation. Goes to ${pathname}/${linkTo}/${id}?mode=read. Omit for default ${pathname}/${id}?mode=read. |
freezeColumnIndex | number | No | 1 | Initial count of left-side data columns kept frozen. The select column remains pinned ahead of the frozen range when enabled. |
Built-in Import / Export
ModelTable ships with built-in import and export dialogs under the toolbar More menu.
Detailed behavior is covered in ModelTable, including:
- import/export tabs and flows
- export scope rules
- history-tab renderer reuse
Toolbar-level custom actions are still declared with <Action placement="toolbar" /> inside children.
initialParams Guide
initialParams is the initial server query state for ModelTable and follows:
type initialParams = QueryParamsWithoutFields;ModelTable does not accept top-level initialParams.fields.
The table query field list always comes from visible <Field /> children in declaration order.
Recommended split:
- use top-level
ordersfor normal default sorting - use
initialParamsfor advanced query concerns such asfilters,pageSize,groupBy,effectiveDate, orsubQueries - if both
ordersandinitialParams.ordersare provided, top-levelorderswins
initialParams.filters remains a normal server-side base filter for the table query itself. It does not resolve {{ expr }} references; that declarative syntax is only supported on relation-field filters.
Query bootstrap defaults:
pageNumber = 1pageSize = 20- others are
undefined
initialParams Fields
| Key | Type | Default | Notes |
|---|---|---|---|
filters | FilterCondition | undefined | Base filter condition. This is treated as the base and merged with UI filters using AND. |
orders | OrderCondition | undefined | Initial sort order. |
pageNumber | number | 1 | Initial page number. |
pageSize | number | 20 | Initial page size. |
aggFunctions | Array<string | string[]> | undefined | Advanced aggregation functions (when backend supports them). |
groupBy | string[] | undefined | Initial group-by fields. |
splitBy | string[] | undefined | Advanced split/group dimension fields. |
summary | boolean | undefined | Whether summary mode is enabled for the query. |
effectiveDate | string | undefined | Effective date snapshot (time-travel style query). |
subQueries | Record<string, SubQuery> | undefined | Related/sub-query payloads. |
Minimal Example
<ModelTable modelName="UserAccount" orders={["updatedTime", "DESC"]} >
<Field fieldName="username" />
<Field fieldName="email" />
<Field fieldName="status" />
<Field fieldName="updatedTime" />
</ModelTable>Recommended Example (top-level filters + orders)
<ModelTable
modelName="UserAccount"
filters={[["status", "!=", "Deleted"], "AND", ["locked", "=", false]]}
orders={["updatedTime", "DESC"]}
initialParams={{ pageSize: 50, effectiveDate: "2026-03-01" }}
>
<Field fieldName="username" />
<Field fieldName="email" />
<Field fieldName="status" />
<Field fieldName="locked" />
<Field fieldName="updatedTime" />
</ModelTable>filters and orders go on the top level. initialParams carries the
remaining advanced fields.
Advanced Example (groupBy / aggFunctions / subQueries)
<ModelTable
modelName="UserAccount"
filters={["status", "=", "Active"]}
initialParams={{
groupBy: ["departmentId"],
aggFunctions: [["COUNT", "*", "count"]],
subQueries: {
roles: {
fields: ["id", "name"],
orders: [["name", "ASC"]],
topN: 5,
},
},
}}
>
<Field fieldName="departmentId" />
<Field fieldName="status" />
</ModelTable>Filter precedence and merge behavior
The base filter is resolved by picking the first non-undefined source (no merge within this layer):
top-level filters > initialParams.filters > MultiView.Tab.filters (context)The chosen base is then AND-merged with all other filter sources at runtime:
- chosen base filter (above)
- workspace filter (
useWorkspaceFilter()— security/scope) - side panel filter (
SideTree/SideCard/SideListselection) - search filter (
["searchName", "CONTAINS", keyword]) - column filter tags
- toolbar condition filter
Example merged condition:
[
["status", "=", "Active"],
"AND",
["locked", "=", true],
"AND",
["searchName", "CONTAINS", "alice"],
];For the full layered model (including how MultiView.Tab.filters interacts
with this), see
Filter & order precedence.
Actions
Common Action / BulkAction API now lives in Actions.
This section keeps only the ModelTable container rules and a complete table-level example.
Rules:
<Action placement="toolbar" />renders in the table toolbar custom action area<Action placement="inline" />renders in the last-column inline action area<Action placement="more" />renders in the last-column More Actions dropdown- active inline-edit rows resolve action context from the current draft row values
- clicking a row action while the active row is dirty asks whether to discard the draft before continuing
BulkActionis selection-scoped and only shown when rows are selectedBulkAction placement="toolbar"appears betweenColumnsandMoreBulkAction placement="more"appears in the toolbar More dropdown bulk section- built-in
Delete selectedshares that bulk section
Action callbacks in table receive row execution context:
onClick: ({ id, modelName, scope, mode, isDirty, values, row }) => voidComplete example:
import { Action } from "@/components/actions/Action";
import { BulkAction } from "@/components/actions/BulkAction";
import { Field } from "@/components/fields";
import { ActionDialog } from "@/components/views/dialogs";
import { ModelTable } from "@/components/views/table/ModelTable";
import { ExternalLink, Lock, Pencil, ShieldCheck } from "lucide-react";
function UnlockDialog() {
return (
<ActionDialog title="Unlock Account">
<Field fieldName="reason" labelName="Reason" widgetType="Text" />
</ActionDialog>
);
}
<ModelTable modelName="UserAccount">
<Field fieldName="username" />
<Field fieldName="email" />
<Field fieldName="status" />
<Action
type="custom"
labelName="Refresh"
placement="toolbar"
onClick={() => console.log("refresh")}
/>
<Action
type="custom"
labelName="Quick Edit"
placement="inline"
icon={Pencil}
onClick={({ id }) => {
console.log("quick edit:", id);
}}
/>
<Action
labelName="Lock Account"
placement="more"
icon={Lock}
operation="lockAccount"
confirmMessage="Lock this account?"
successMessage="Account locked."
/>
<Action
type="dialog"
labelName="Unlock Account"
placement="more"
icon={ShieldCheck}
operation="unlockAccount"
component={UnlockDialog}
successMessage="Account unlocked."
/>
<Action
type="link"
labelName="Open Audit"
placement="more"
icon={ExternalLink}
href={({ id }) => `/user/user-account/${id}/audit`}
/>
<BulkAction
labelName="Lock Selected"
operation="lockByIds"
placement="toolbar"
confirmMessage={({ ids }) => `Lock ${ids.length} selected accounts?`}
/>
<BulkAction
type="dialog"
labelName="Unlock Selected"
operation="unlockByIds"
placement="more"
component={UnlockDialog}
/>
</ModelTable>;Built-in Bulk Edit action:
- location:
Moredropdown bulk section in toolbar - behavior: supports adding multiple field edits in one submit
- value editor: rendered by field type (
Boolean, numeric, date/time, text/json, option, etc.) - submit API:
updateByFilterwithfilters = ["id","IN", selectedIds],values = { ...editedFields }
<ModelTable
modelName="UserAccount"
bulkEditFields={["status", "email", "phoneNumber", "locked"]} // optional
excludeFields={["email"]} // optional
>
<Field fieldName="username" />
<Field fieldName="email" />
<Field fieldName="status" />
<Field fieldName="locked" />
</ModelTable>If bulkEditFields is not provided, Bulk Edit uses all available metadata fields.
Even when bulkEditFields is provided, excluded fields are still removed.
Built-in reserved fields are always excluded:
id, createdTime, createdId, createdBy, updatedTime, updatedId, updatedBy, tenantId.