Skip to Content

ModelTable

Composable data table view with:

  • metadata-driven columns
  • server-side query integration
  • toolbar filter/sort/group controls
  • optional side tree filter panel

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 fields are generated automatically from those declarations
  • top-level orders is the recommended way to declare default sorting
  • initialParams is the advanced query escape hatch for non-column query params such as filters, pageSize, groupBy, effectiveDate
  • children can 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:

  • Field order is the rendered column order
  • widgetType, labelName, filters, onChange, and static required / readonly overrides are reused by both read cells and inline editors
  • defaultValue is 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[], and JSON / DTO / Filters / Orders stay 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, filters may 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_ID are passed through unchanged; literals use {{ 'value' }} or backend tokens like {{ NOW }} as needed
  • hidden only supports boolean in table declarations; hidden={true} removes the whole column
  • conditional required / readonly are supported in inline edit, but conditional hidden is not

Detailed field value contracts are documented in Field & Widget.

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:

  • File expects FileInfo
  • MultiFile expects FileInfo[]
  • 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 dialog
  • MultiFile + widgetType="MultiImage" renders a compact thumbnail summary with +N; clicking it opens a gallery-style preview dialog
  • plain File renders a downloadable filename link to FileInfo.url
  • plain MultiFile renders 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, then id as fallback
  • if no item can produce a label, the cell falls back to a count summary such as 3 items
  • this applies to both ModelTable and RelationTable read cells
  • widgetType="TagList" does not change the read cell renderer; it only enables the searchable multi-select editor for ManyToMany in 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 mode
  • true: row click activates inline edit for that row
  • editable cells render Field directly inside the table cell
  • active row shows row-level Save / Cancel
  • Save stays disabled until the active row has actual changes
  • Save submits only changed editable fields for that row via update API
  • Cancel restores the row from the latest loaded server snapshot
  • switching to another row while current row is dirty asks for discard confirmation
  • required / readonly support boolean, FilterCondition, and dependsOn([...], evaluator)
  • inline-edit conditions are evaluated against the current row object with scope="model-table", plus rowIndex and rowId
  • 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, and MultiImage participate in inline edit and reuse the normal Field upload widgets inside the active row
  • OneToMany stays read-only in table inline edit
  • ManyToMany participates in table inline edit only when widgetType="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 values patch only the current row
  • response readonly / required apply 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

PropTypeRequiredDefaultNotes
modelNamestringYes-Used to fetch metadata API.
labelNamestringNo-Overrides the page title shown in the table header. Defaults to metaModel.labelName when omitted.
descriptionstringNo-Overrides the subtitle shown in the table header. Defaults to metaModel.description when omitted.
inlineEditbooleanNofalseEnable row-click inline edit mode. When enabled, active-row editable cells render Field components instead of navigating to detail.
ordersOrderConditionNo-Recommended default sort. Supports a single tuple (["createdTime", "DESC"]) or multiple tuples. Wins over initialParams.orders and MultiView.Tab.orders (context).
filtersFilterConditionNo-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.
initialParamsQueryParamsWithoutFieldsNo-Advanced initial query settings (pageSize, groupBy, effectiveDate, subQueries, splitBy, aggFunctions, summary). For filters / orders, prefer the top-level props.
childrenReactNodeNo-Ordered <Field /> declarations plus optional <Action />, <BulkAction />, and one side panel (<SideTree>, <SideCard>, or <SideList>). At least one visible <Field /> is required at runtime.
enableBulkDeletebooleanNotrueEnable built-in bulk delete entry.
enableCreatebooleanNotrueEnable built-in create button.
enableImportbooleanNotrueEnable built-in import dialog entry in More menu.
enableExportbooleanNotrueEnable built-in export dialog entry in More menu.
bulkEditFieldsstring[]No-Optional bulk-edit allowlist. If omitted, built-in Bulk Edit uses all metadata fields.
excludeFieldsstring[]No-Optional bulk-edit denylist. Always excluded from built-in Bulk Edit (in addition to reserved fields).
linkTostringNo-Subdirectory name (single segment) for row click navigation. Goes to ${pathname}/${linkTo}/${id}?mode=read. Omit for default ${pathname}/${id}?mode=read.
freezeColumnIndexnumberNo1Initial 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 orders for normal default sorting
  • use initialParams for advanced query concerns such as filters, pageSize, groupBy, effectiveDate, or subQueries
  • if both orders and initialParams.orders are provided, top-level orders wins

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 = 1
  • pageSize = 20
  • others are undefined

initialParams Fields

KeyTypeDefaultNotes
filtersFilterConditionundefinedBase filter condition. This is treated as the base and merged with UI filters using AND.
ordersOrderConditionundefinedInitial sort order.
pageNumbernumber1Initial page number.
pageSizenumber20Initial page size.
aggFunctionsArray<string | string[]>undefinedAdvanced aggregation functions (when backend supports them).
groupBystring[]undefinedInitial group-by fields.
splitBystring[]undefinedAdvanced split/group dimension fields.
summarybooleanundefinedWhether summary mode is enabled for the query.
effectiveDatestringundefinedEffective date snapshot (time-travel style query).
subQueriesRecord<string, SubQuery>undefinedRelated/sub-query payloads.

Minimal Example

<ModelTable modelName="UserAccount" orders={["updatedTime", "DESC"]} > <Field fieldName="username" /> <Field fieldName="email" /> <Field fieldName="status" /> <Field fieldName="updatedTime" /> </ModelTable>
<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 / SideList selection)
  • 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
  • BulkAction is selection-scoped and only shown when rows are selected
  • BulkAction placement="toolbar" appears between Columns and More
  • BulkAction placement="more" appears in the toolbar More dropdown bulk section
  • built-in Delete selected shares that bulk section

Action callbacks in table receive row execution context:

onClick: ({ id, modelName, scope, mode, isDirty, values, row }) => void

Complete 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: More dropdown 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: updateByFilter with filters = ["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.

Last updated on