ModelTable
Composable data table view with:
- metadata-driven columns
- server-side query integration
- toolbar filter/sort/group controls
- optional side tree filter panel
- automatic permission gating by
modelName(see §“Permission Integration”)
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
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}
/>
</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,label,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="lastActivityId.status" 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 / label) 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="label" readonly={[["active", "=", false]]} />
<Field fieldName="active" />
</ModelTable>dependsOn() example:
import { dependsOn, Field } from "@/components/fields";
<Field
fieldName="label"
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 (unlessenableClick={false}, which disables row click entirely)true: row click activates inline edit for that row (takes precedence overenableClick)- 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="label"
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.
Field Capability Rules
Whether a field shows up in the toolbar’s filter / sort / groupby selectors and whether its column header renders a filter / sort icon is decided by a single set of helpers in src/components/views/table/utils/field-capability.ts. Toolbar selectors and column headers use the same helpers so they always agree.
isFilterableField— excludesdynamic=trueonly. AllFieldTypevalues (includingFile,MultiFile,OneToMany,ManyToMany,JSON,Filters,Orders,DTO) keep filter capability becausefilter-operators.tsdefines operators for them (at minimumIS SET/IS NOT SET).isSortableField— excludesdynamic=trueand the eight non-sortableFieldTypevalues:File,MultiFile,OneToMany,ManyToMany,JSON,Filters,Orders,DTO.isGroupableField— shares the same rule asisSortableField.
A dynamic=true field never appears in the filter / sort / groupby selectors and its column header renders neither a filter icon nor a sort icon.
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.
View State Persistence
The table remembers its query state so that navigating into a record detail page and clicking Back — or refreshing the same tab — returns you to exactly where you were instead of resetting to the default view.
Persisted query state: pagination (page number + page size), sorting, group-by, the toolbar filter tree, column header filters, the search term, and side-panel (SideTree / SideList / SideCard) selection. State is stored in sessionStorage, scoped per view kind + modelName + route + active MultiView tab, via the shared src/components/views/shared/view-state/ module.
Behavior:
- Restore is “always on mount”: re-entering the same view in the same tab (including from the sidebar) restores the last query state.
Clear allresets it, so the next visit starts fresh. sessionStoragesurvives reloads and same-tab Back/forward, and is isolated per browser tab. It is cleared when the tab is closed and is not shared to newly opened tabs — query state is intentionally ephemeral and not part of the URL (the list URL stays clean and is not a shareable filtered view).- Row selection is never persisted.
- Column layout (order / visibility / pinning / freeze) is a durable preference and is not persisted by this mechanism yet.
- Side-panel selection persists the selection source (selected ids + filter field + operator + minimal labels), not the derived filter condition. On restore the panel re-highlights, the query is filtered, and the toolbar “Filter status” tag is shown — all derived from that one source. For
SideTree, highlight restore matches node ids; whenfilterValueFieldis customized (filter value ≠ node id) the query still restores correctly while the tree highlight keys off node id.
ModelBoard (search term only) and ModelCard (pagination + sort + filter + search) share the same mechanism.
Data Freshness
Restoring where you were is only half of Back navigation — the data may have changed while you were on the detail page (form save, action buttons, server-side cascades, other users). Two layers keep views fresh:
- Stale-while-revalidate everywhere: the global query default is
staleTime: 0(src/providers/query-provider.tsx), so every mount renders cached data instantly — no skeleton flash — while a background refetch replaces it with fresh data. This covers the table’s main query, side panels, sidecar, and detailgetByIdalike, and is the safety net for changes no invalidation call can enumerate. Hooks with genuinely static data (metadata, option sets, dial codes, default values) pin their own longerstaleTime. - Prefix invalidation on write: every mutation path (form save, default / dialog / custom actions, bulk operations, delete) calls
invalidateModelQueries, which invalidates all cached queries whose key starts with the model name — table pages, side panels, board counts, sidecar joins, and record caches alike. This is what refreshes views that are still mounted when the write happens.
Core Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
modelName | string | Yes | - | Used to fetch metadata API. |
label | string | No | - | Overrides the page title shown in the table header. Defaults to metaModel.label 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. |
enableClick | boolean | No | true | Whether clicking a row navigates to the detail page. Set false for view-only / report tables that have no detail route, so rows are not rendered as clickable (no cursor-pointer, no navigation). Ignored when inlineEdit is enabled. |
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
The header search box is driven by model metadata (resolveTableSearchConfig): its placeholder reads Search by <label>... using the label of the field(s) the backend actually matches — the model’s configured searchName, falling back to the name field. When the model declares neither searchName nor a name field there is nothing to free-text search against, so the search box is hidden entirely.
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.
Permission Integration
ModelTable auto-gates by modelName against the generated MODEL_PERMISSIONS lookup — business pages do not call usePermission for standard CRUD or for any custom action that declares a permission field.
What gets gated for free, just from passing modelName:
| Built-in control | Action segment checked | Effect when denied |
|---|---|---|
| Toolbar “Create” | create | Hidden (alongside quickAdd) |
| Toolbar “Import” | import | Hidden |
| Toolbar “Export” | export | Hidden |
| Toolbar bulk “Delete” | delete | Hidden + row-selection auto-disabled if no other bulk action |
| Inline edit | update | Force-disabled even when inlineEdit prop is true |
Custom <Action permission="…" /> / <BulkAction permission="…" /> children are filtered before render — no flash, no click handler installed. Pass the manifest’s action segment ("transfer", "initiate-signing", or any of the standard CRUD names) — useActionPermission(modelName, permission) does the lookup internally.
Status semantics (intentional three-state):
- Granted → control / action stays visible (business
enable*andhiddenstill apply). - Denied → control / action is hidden; row selection / inline edit also collapses if it depends on the missing right.
- Unmanaged → the
(modelName, action)pair isn’t inMODEL_PERMISSIONSbecause the model is ambiguous across pages (seepnpm gen:permissionswarnings) or simply not declared. Auto-gate has no opinion and lets the page’s ownenableCreate=/hidden=/usePermissiondecide. Use this escape hatch for pages on shared models likeEmpDocument/LeaveRequest.
Business props still win when set to false — a page that explicitly passes enableCreate={false} keeps Create hidden for SUPER_ADMIN too. SUPER_ADMIN short-circuits the permission check itself (every action returns Granted) but does NOT override an explicit business false.
Example — auto-gated list with one custom action:
<ModelTable modelName="Employee">
<Field fieldName="fullName" />
<Action
type="custom"
label="Transfer"
placement="more"
permission="transfer" {/* gated by Employee.transfer */}
onClick={openTransferDialog}
/>
</ModelTable>The toolbar Create / Import / Export / bulk Delete are auto-shown / auto-hidden purely from modelName="Employee"; “Transfer” is auto-hidden when the user lacks Employee.transfer.
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" label="Reason" widgetType="PlainText" />
</ActionDialog>
);
}
<ModelTable modelName="UserAccount">
<Field fieldName="username" />
<Field fieldName="email" />
<Field fieldName="status" />
<Action
type="custom"
label="Refresh"
placement="toolbar"
onClick={() => console.log("refresh")}
/>
<Action
type="custom"
label="Quick Edit"
placement="inline"
icon={Pencil}
onClick={({ id }) => {
console.log("quick edit:", id);
}}
/>
<Action
label="Lock Account"
placement="more"
icon={Lock}
operation="lockAccount"
confirmMessage="Lock this account?"
successMessage="Account locked."
/>
<Action
type="dialog"
label="Unlock Account"
placement="more"
icon={ShieldCheck}
operation="unlockAccount"
component={UnlockDialog}
successMessage="Account unlocked."
/>
<Action
type="link"
label="Open Audit"
placement="more"
icon={ExternalLink}
href={({ id }) => `/user/user-account/${id}/audit`}
/>
<BulkAction
label="Lock Selected"
operation="lockByIds"
placement="toolbar"
confirmMessage={({ ids }) => `Lock ${ids.length} selected accounts?`}
/>
<BulkAction
type="dialog"
label="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.