Tables
Overview
A table view is built from a data controller. You declare the controller's routes with TableViewRoutes, annotate its fields with @Column, and then call TableView(controller, options) to get the component. The builder reads the controller's data-api metadata — access modes, mandatory modes, listable flags, sortability — and combines it with the column options to produce the column list, the new / edit / view forms, and the set of permission-carrying actions.
Everything on this page is exported from the table-view module, reachable from the package root or from its own subpath:
import { Column, TableView, TableViewRoutes } from "@antelopejs-private/cms/interfaces/cms-base";
// or
import { Column, TableView } from "@antelopejs-private/cms/interfaces/cms-base/table-view";
Declare the Data Controller
TableViewRoutes.All is the route set a table view expects. Field decorators from @antelopejs/interface-data-api/metadata stay exactly as they are on any data controller; @Column is added on top of them.
import { Controller } from "@antelopejs/interface-api";
import {
DataController,
RegisterDataController,
} from "@antelopejs/interface-data-api";
import {
Access,
AccessMode,
Listable,
Mandatory,
ModelReference,
Sortable,
} from "@antelopejs/interface-data-api/metadata";
import { Model } from "@antelopejs/interface-database-decorators";
import {
Column,
Exported,
Searchable,
Select,
TableViewRoutes,
} from "@antelopejs-private/cms/interfaces/cms-base";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types";
import { ReadonlyBehaviorType } from "@antelopejs-private/cms/interfaces/cms-base/types";
import { Role, RoleModel } from "./db";
@RegisterDataController()
export class roleDataAPI extends DataController(
Role,
TableViewRoutes.All,
Controller("/api/tables/roles"),
) {
@ModelReference()
@Model(RoleModel)
declare model: RoleModel;
@Select()
@Listable()
@Exported()
@Access(AccessMode.ReadOnly)
declare _id: string;
@Select()
@Listable()
@Searchable()
@Exported()
@Column({
name: "Name",
type: new DefaultDataTypes.StringType({ placeholder: "Role name" }),
filterable: true,
})
@Mandatory("new", "edit")
@Sortable()
@Access(AccessMode.ReadWrite)
declare name: string;
@Exported()
@Column({
name: "Created at",
type: new DefaultDataTypes.DateType(),
readonlyBehavior: {
new: ReadonlyBehaviorType.hidden,
edit: ReadonlyBehaviorType.disabled,
view: ReadonlyBehaviorType.disabled,
},
})
@Access(AccessMode.ReadOnly)
declare createdAt: Date;
}
TableViewRoutes
| Route | Endpoint role |
|---|---|
Get | Read one row. |
List | Paginated list with search, filters and sorting. |
Select | List with the select pluck mode, used by relation pickers. |
Count | Row count for the current query. |
New | Create a row. |
Edit | Update a row. |
Delete | Delete rows. |
Archive | Set the archive field on rows. |
Restore | Clear the archive field on rows. |
ExportRoutes | A record with exportStart (/export/start), exportStatus (/export/status/:exportId) and exportDownload (/export/download/:exportId). |
All | Every route above, spread into one object. |
These are not the plain data-api defaults. Every route asserts tenant access and the action's permission; the mutating ones additionally run the matching guard and report the mutation to the realtime bridge, and Edit, Delete, Archive and Restore also validate the affected rows against the action's rule. New and Edit promote staged file uploads before writing, and Edit and Delete remove the files a row no longer references afterwards. Passing a narrower set than All removes the corresponding capabilities — the export action, for instance, is only offered when all three ExportRoutes endpoints are registered.
@Searchable on the example's name field is what makes the list route's free-text search consider that column; it is documented in Helpers.
The Column Decorator
@Column is a method-and-property decorator. It records the column, and unless validate is false it registers a data-api Validator built from the data type's getValidation() schema (wrapped for localization when the underlying table field carries the LocalizationModifier). When filterable is set it registers the data type's filter implementation as the field's data-api Filter. Finally, if the data type defines decorateField, that hook runs — this is how a relation type installs its Foreign join.
| Option | Type | Description |
|---|---|---|
name | string | Column header, and the label of the generated form field. Required. |
type | DataType | Data type instance. Required. |
description | string | Description carried onto the generated form field. |
order | number | Sort key among columns; missing values sort as 0. |
isVisible | boolean | Whether the column is shown by default. |
defaultValue | DefaultValue | Default value for the column and the generated form field. |
readonlyBehavior | ReadonlyBehavior | Per-mode behaviour for a read-only field; see Shared Types. |
validate | boolean | Set to false to skip registering the validator. |
filterable | boolean | Register the type's filter and allow column filtering. |
inputComponent | Component | Overrides the input component the data type supplies. |
group | string | Identifier of the ColumnGroup this column belongs to in the generated forms. |
A read-only field whose readonlyBehavior resolves to hidden for a mode is dropped from that mode's form; a field whose data type has no input component is dropped from every form.
Column Groups
ColumnGroup(id, config) is a class decorator on the data controller. Columns join a group by naming its id in ColumnOptions.group; the generated forms then render a FieldGroup in the position of the group's first column.
import { Column, ColumnGroup } from "@antelopejs-private/cms/interfaces/cms-base/table-view";
@ColumnGroup("contact", { label: "Contact", orientation: "horizontal", order: 2 })
export class customerDataAPI extends DataController(/* … */) {
@Column({ name: "Email", type: new DefaultDataTypes.EmailType(), group: "contact" })
declare email: string;
@Column({ name: "Phone", type: new DefaultDataTypes.PhoneType(), group: "contact" })
declare phone: string;
}
ColumnGroupConfig has label: string (required), description?, orientation?: "horizontal" | "vertical" and order?: number.
Select and Exported
@Select(requiredFields?) and @Exported(requiredFields?) mark a field as listable in the select and export pluck modes respectively. Both forward to Listable from @antelopejs/interface-data-api/metadata with the corresponding mode, and both accept the same boolean | string[] argument that Listable takes for dependent fields.
The Archive Field
@ArchiveField marks the boolean field that carries the archived state. It records the field on the table view metadata and also applies Filter() and Listable() to it, because archive filtering needs both.
import { ArchiveField } from "@antelopejs-private/cms/interfaces/cms-base/table-view";
@ArchiveField()
@Access(AccessMode.ReadOnly)
declare archived: boolean;
DEFAULT_ROW_ID_FIELD is the constant "_id" — the row identifier used when TableViewOptions.rowIdKey is not set.
Build the Table View
TableView returns a ComponentBuilder<TableViewOptionsSerialized>, assigned to a static field of a page class.
import {
PageController,
pagesCategory,
RegisterPage,
} from "@antelopejs-private/cms/interfaces/cms/page";
import { TableView } from "@antelopejs-private/cms/interfaces/cms-base";
import { roleDataAPI } from "./role-data-api";
@RegisterPage()
export class RolesPage extends PageController("roles", {
displayName: "Roles",
icon: "i-ph-shield-check",
category: pagesCategory,
}) {
static table = TableView(roleDataAPI, {
caption: "Roles",
labelKey: "name",
rowActions: {
add: true,
edit: true,
delete: true,
details: true,
hasSelection: true,
},
});
}
Signature
function TableView<T extends ControllerClass>(
controller: T,
options?: TableViewOptions<InstanceType<T>>,
): ComponentBuilder<TableViewOptionsSerialized>;
Typing TableViewOptions by InstanceType<T> is what makes row-action rules field-name-checked against the controller's own fields.
TableViewOptions
| Option | Type | Description |
|---|---|---|
caption | string | Table caption; also the component's display name, defaulting to "TableView". |
rowIdKey | string | Field holding the row id. Defaults to DEFAULT_ROW_ID_FIELD. |
labelKey | string | Field used to name a row in container titles. |
rowActions | TableViewRowActionOptions<T> | Built-in and custom row actions. |
customButtons | CustomButton[] | Toolbar buttons; see Shared Types. |
formContainer | FormContainer | Where the new / edit / view forms open. |
archiveMode | boolean | Replace delete with archive/restore. Requires @ArchiveField. |
defaultFilters | Array<{ accessorKey: string; value?: string; mode: string }> | Filters applied when the table opens. |
defaultSort | { field: string; desc?: boolean } | Sort applied when the viewer has no stored preference. |
queryParamFilters | QueryParamFilters | Maps URL query parameters onto hidden filters. |
routeParamFilters | RouteParamFilters | Maps CMS route parameters onto hidden filters. |
strictRuleValidation | boolean | Reject the whole request when any row fails rule validation, instead of processing only the eligible rows. |
tabs | TableViewTab[] | Tabs above the table, each applying a set of hidden filters. |
realtime | boolean | Set to false to disable realtime broadcast and presence for this resource. Realtime is on by default. |
kanban | KanbanOptions | Enable the kanban display. |
displays | TableViewDisplayOption[] | Additional displays beyond the built-in table. |
defaultDisplay | string | Display shown when the viewer has no stored preference. Defaults to "table". |
guards | TableViewGuards<T> | Server-side guards run before mutating actions. |
bypassTenantAccessGate | boolean | Skip the tenant access gate on every data route of this table view. |
Reaching a Table Behind a Tenant Gate
bypassTenantAccessGate is the table-view mirror of the guard option of the same name. Reserve it for tables that must stay readable while a gate denies the tenant — the invoices on the billing page of a suspended tenant, so it can regularize its situation. Permission checks still apply.
Data routes are registered against the controller, not against a page, so the opt-out is controller-level and latched: once any TableView() call on a controller declares it, the controller's routes bypass the gate wherever they are rendered. A later registration of the same controller without the flag cannot re-gate them, and gets a warning saying so. Two consequences worth designing around:
- Declaring the flag on a controller opens its routes for every table view built on it, including one mounted on an ordinary page.
- Actions that carry no permission id — a plain list on a component that was never mounted — are then served to any member of a denied tenant. That is the escape hatch working as intended, so keep every action of a flagged controller stamped with a permission.
TABLE_DISPLAY_ID ("table") and KANBAN_DISPLAY_ID ("kanban") are exported constants for the two built-in display ids. TableViewOptionsSerialized is the transported shape: customButtons, rowActions, displays become their serialized counterparts, kanban is folded into displays, and two fields are added — enableTableExport: boolean and formComponents, a record of the serialized new / edit / view forms.
TableView throws at declaration time when the options do not hold together: archiveMode without an @ArchiveField, a kanban.groupByField that is not a column or whose type is not a non-multiple SelectType, a BooleanType or a StatusType, a kanban.cardFields entry that is not a column, or a defaultDisplay that no declared display provides.
Row Actions
TableViewRowActionOptions<T> configures the row menu. Every built-in action takes true, false, or a RowActionConfig<T> carrying a rule.
| Option | Type | Description |
|---|---|---|
add | boolean | RowActionConfig<T> | Create a row. |
edit | boolean | RowActionConfig<T> | Edit a row. |
details | boolean | RowActionConfig<T> | Open the read-only view. |
duplicate | boolean | RowActionConfig<T> | Open the new form pre-filled from the row. |
delete | boolean | RowActionConfig<T> | Delete a row. |
archive | boolean | RowActionConfig<T> | Archive a row. |
restore | boolean | RowActionConfig<T> | Restore an archived row. |
copyLink | boolean | RowActionConfig<T> | Copy a link to the row. |
hasSelection | boolean | Enable row selection. |
custom | CustomRowAction<T>[] | Extra actions pointing at an ActionTarget. |
static table = TableView(memberDataAPI, {
rowActions: {
add: false,
edit: { isEnabled: true, isVisible: true },
delete: { isEnabled: true, rule: { field: "isTenantOwner", notEquals: true } },
hasSelection: true,
custom: [
{
label: "Validate email",
icon: "i-ph-check-circle",
target: {
type: "api",
url: "/settings/user/members/{_id}/validate-email",
method: "POST",
successMessage: "Email validated",
},
rule: { field: "isValidated", notEquals: true },
},
],
},
});
Rules are enforced on both sides. When any action carries a rule, TableView stores the whole rowActions object on the controller metadata as the controller's rule set, and the edit, delete, archive and restore routes validate the affected ids against it before running. Rows that fail the rule are dropped from the operation; with strictRuleValidation the whole request is rejected instead. Because the rules live on the controller, a second table view on the same controller that declares no rule of its own still gets the controller's rule merged into its serialized row actions, so its menu matches what the server accepts.
Permissions are applied on top. The table view registers list and select as component actions always, add, edit and view when the matching generated form exists, delete when the controller declares the delete endpoint, archive, restore and viewArchived under archiveMode, and export when the three export endpoints are registered. Each action carries a permission id of the form <component-permission>.<action>. When the viewer lacks the permission the corresponding row action is forced to false and the corresponding generated form is withheld. The rule algebra itself — leaf operators, and / or / not, and RowActionConfig — is documented in Shared Types.
TableViewRowActionOptionsSerialized is the transported shape: rules and flags untyped, custom as CustomRowActionSerialized[], plus a showArchived?: boolean field the builder fills from the viewArchived permission.
Archive Mode
With archiveMode: true, the archive and restore actions replace deletion. The builder calls applyArchiveModeDefaultRules (see Helpers) to give every relevant action a default rule against the @ArchiveField column, so archived rows offer restore and delete while live rows offer edit, duplicate and archive. Actions you configured explicitly keep your settings; only actions left undefined, set to true, or given without a rule receive the default.
static table = TableView(articleDataAPI, {
archiveMode: true,
rowActions: { edit: true, delete: true, archive: true, restore: true },
});
Guards
A guard runs before the mutating route and aborts it by throwing. Use assert from @antelopejs/interface-api-util to fail with an HTTP status and a message key. this inside a guard is the data controller instance.
import { assert } from "@antelopejs/interface-api-util";
import { getRequestTenantId } from "@antelopejs-private/cms/interfaces/cms/request-tenant";
import { GetModel } from "@antelopejs/interface-database-decorators";
import { TenantMemberModel } from "@antelopejs-private/cms/interfaces/cms/db";
static table = TableView(memberDataAPI, {
guards: {
edit: async (ctx, { current, body }) => {
const demoting = current.isTenantOwner && body.isTenantOwner === false;
if (!demoting) return;
const model = GetModel(TenantMemberModel, getRequestTenantId(ctx));
const remaining = await model.countOwnersExcluding([current.userId]);
assert(remaining > 0, 409, "$page.settings.members.error.last_owner");
},
delete: async (ctx, { ids }) => {
// ids is the full set of rows the request targets
},
},
});
The edit guard receives { id, body, current }, where current is the row as it stands in the database — the route loads it before calling the guard and answers 404 when it does not exist. The new guard receives { body }, parsed from the request body whether it arrived as a buffer, a string or an object. The delete, archive and restore guards receive { ids }. Like the rules, guards are stored on the controller metadata the first time a table view declares them, so they apply to every table view on that controller. The argument interfaces are documented in Shared Types.
Filters, Tabs and Displays
defaultFilters seeds the visible filter bar. queryParamFilters and routeParamFilters add hidden filters instead: each is a Record<string, QueryParamFilter> / Record<string, RouteParamFilter> mapping a URL parameter name onto { field: string; mode?: string }, filtering the table without appearing in the UI or in the viewer's stored preferences. queryParamFilters reads the page's query string, routeParamFilters reads the parameters of the page slug (for example :id on a detail page). Both also seed submitDefaults on the generated forms, so a row created from a filtered view inherits the filter's value.
static table = TableView(invoiceDataAPI, {
routeParamFilters: { id: { field: "customerId" } },
defaultSort: { field: "createdAt", desc: true },
tabs: [
{ id: "open", label: "Open", filters: [{ accessorKey: "status", value: "open", mode: "is" }] },
{ id: "paid", label: "Paid", filters: [{ accessorKey: "status", value: "paid", mode: "is" }] },
],
});
TableViewTab is { id, label, filters: TableViewTabFilter[], icon?, textColor?: ColorValue, iconColor?: ColorValue } and TableViewTabFilter is { accessorKey: string; value?: string; mode: string }. Declaring at least one tab also surfaces an implicit "all" tab that applies no filters.
displays declares alternative renderings of the same data:
| Field | Type | Description |
|---|---|---|
id | string | Display identifier. Required. |
options | Record<string, unknown> | Options handed to the display. |
component | Component | Frontend component, resolved by name from the global registry. |
selfManagedData | boolean | The display fetches its own data. |
capabilities | TableViewDisplayCapabilities | Which toolbar features the display supports: columnManagement, filters, search, sorting, tabs, all optional booleans. |
TableViewDisplayOptionSerialized is the same with component serialized.
kanban is a shorthand for one particular display: the builder appends a displays entry with id "kanban" carrying the kanban options.
| Option | Type | Description |
|---|---|---|
groupByField | string | Column the cards are grouped by. Must be a non-multiple SelectType, a BooleanType or a StatusType column. Required. |
cardFields | string[] | Columns rendered on the card below labelKey. Each must be a declared column. |
cardComponent | Component | Custom card component resolved by name from the global registry. |
draggable | boolean | Allow dragging a card to another column to change the group field. Defaults to true. |
columnMaxHeight | string | CSS max-height of a column's card list before it scrolls. Defaults to "60vh". |
KanbanOptionsSerialized is KanbanOptions with cardComponent serialized.
Generated Forms
TableView builds up to three forms from the columns, one per FormMode, using each column's data type for the input and the controller's mandatory and access metadata for required and disabled state. The new form fetches from <location>/get?id={{query.duplicate}} and submits to <location>/new; the edit form fetches from <location>/get?id={{params.id}}&_presence=1 — the extra parameter makes the Get route report the viewer's presence on that row to the realtime bridge — and submits to <location>/edit?id={{params.id}}; the view form only fetches, from <location>/get?id={{params.id}}. A mode whose column set is empty produces no form and no matching action.
formContainer decides where they open:
type FormContainer =
| { type: "drawer" }
| { type: "modal"; size?: ModalSize }
| { type: "page"; pages?: { new?; edit?; view?: FormContainerPageConfig } };
Page mode is the default — it is what you get when formContainer is omitted. In page mode the builder registers a hidden sub-page per form under the table's page, with slugs new, :id/edit and :id/view, each guarded by the matching action's permission. FormContainerPageConfig overrides them:
| Option | Type | Description |
|---|---|---|
urlSlug | string | Slug of the sub-page. The edit and view slugs must contain :id, otherwise TableView throws. |
displayName | string | Sub-page title. |
description | string | Sub-page description. |
customPage | boolean | Do not auto-create the sub-page — you registered your own page at that URL. The slug is still used for navigation. |
Exports
The export action is offered only when the controller registered all three ExportRoutes. Pressing it starts a background export job, polls its status, and downloads the result; the job machinery itself is shared with any other long export and is documented in Helpers. Which columns land in the file is decided by @Exported.
Realtime
Unless realtime: false is set, a table view registers a realtime topic for its page (and for each generated sub-page) and reports every create, update and delete to the realtime bridge. Two functions let other modules observe those mutations:
import {
registerRealtimeMutationListener,
unregisterRealtimeMutationListener,
type RealtimeMutationContext,
} from "@antelopejs-private/cms/interfaces/cms-base/table-view";
const listener = (context: RealtimeMutationContext) => {
// context.controllerLocation, context.eventType, context.ids, …
};
registerRealtimeMutationListener(listener);
Listeners are additive side observers: they run after the bridge hook and a listener that throws is logged, never propagated to the mutating request.
| Type | Shape |
|---|---|
RealtimeMutationEventType | "created" | "updated" | "deleted" |
RealtimeMutationContext | controllerLocation, rowIdKey, eventType, ids: string[], sessionId?, actor?: RealtimePresenceActor |
RealtimePresenceActor | id, displayName?, avatarUrl? |
RealtimePresenceContext | controllerLocation, rowIdKey, sessionId, rowId, actor |
RealtimePageTopicContext | pageId, controllerLocation |
setRealtimeMutationHook, setRealtimePresenceHook and setRealtimePageTopicHook each install a single-slot hook owned by the realtime bridge; a module that only wants to observe mutations uses the listener functions above instead.
Metadata
TableViewMeta is the metadata object the decorators write to and the builder reads from. Retrieve it with GetMetadata(controller, TableViewMeta).
| Member | Description |
|---|---|
columns | Record<string, ColumnOptions> keyed by field name. |
groups | Record<string, ColumnGroupConfig> keyed by group id. |
options | The TableViewOptions the last TableView call set. |
archiveField | Field name recorded by @ArchiveField. |
controllerRowActionRules | Row actions promoted to controller-wide rules. |
controllerGuards | Guards promoted to controller-wide guards. |
componentBuilder | The builder of the table view, used to resolve action permissions. |
config | Getter returning { location, columns }, the serialized column list plus the controller's route location. |
getFormFields(mode) | Builds the FormFieldOrGroup[] for "view", "edit" or "new". |
setColumn, setGroup, setOptions, setArchiveField, setControllerRowActionRules, setControllerGuards | Writers used by the decorators and the builder. |
Each entry of config.columns carries id, header, isVisible, type (the serialized data type), accessorKey, listable, enableSorting, enableColumnFilter, defaultValue, accessMode and readonlyBehavior.
Route Helpers
table-view also exports the interface functions the wrapped routes call. They are the seam between the interface and its implementation — a consumer wires them by declaring TableViewRoutes, and does not normally call them directly.
| Function | Role |
|---|---|
listWithSearch | Runs the list query with search, filters, sorting and pagination; returns { results, total, offset, limit }. |
countWithSearch | Same query, count only; returns { total }. |
startExport | Queues an export job; returns { jobId, format, extension }. |
getExportStatus | Returns { status, progress, error? } for a job. |
downloadExport | Streams a completed job's file to the response. |
archiveRows | Sets the archive field on the given ids; returns { success, archivedCount }. |
restoreRows | Clears it; returns { success, restoredCount }. |
validateRowsAgainstRule | Splits ids into { eligibleIds, rejectedIds } against a RowActionRule. |
fetchRowForGuard | Loads one row by id field, for the edit guard and the file-cleanup passes. |
Events and Functions
| Namespace member | Value |
|---|---|
TableViewEvents.ROW_CLICK | "CmsComponent.TableView.RowClick" |
TableViewEvents.ROW_SELECT | "CmsComponent.TableView.RowSelect" |
TableViewEvents.ROW_DELETE | "CmsComponent.TableView.RowDelete" |
TableViewEvents.ROW_ADD | "CmsComponent.TableView.RowAdd" |
TableViewEvents.ROW_EDIT | "CmsComponent.TableView.RowEdit" |
TableViewEvents.FILTER_CHANGE | "CmsComponent.TableView.FilterChange" |
TableViewEvents.SORT_CHANGE | "CmsComponent.TableView.SortChange" |
TableViewEvents.EXPORT | "CmsComponent.TableView.Export" |
TableViewFunctions.CUSTOM_PAGE_FORM_SUCCESS | "CmsComponent.TableView.CustomPageFormSuccess" |
CUSTOM_PAGE_FORM_SUCCESS registers { url: string; preserveQuery?: string[] } in WatchFunctionParamMap. The builder uses it itself: the generated new and edit page forms watch FormEvents.SUBMIT_SUCCESS and navigate back to the table, preserving the query parameters named in queryParamFilters. The view form submits nothing and carries no such watch. All three generated pages are registered with FormPageLayout(), the constrained-column layout (Layout and Containers).
Next Steps
- Charts and Widgets - Charts, KPI cards, top lists and the period selector.