[data-reveal]{opacity:1!important;transform:none!important}
Tables
Interfaces

Tables

TableView over a data controller: columns, routes, row actions, displays, filters and the forms it opens.

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/interface-dms/base";

Or import directly from the table-view subpath:

import { Column, TableView } from "@antelopejs/interface-dms/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/interface-dms/base";
import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types";
import { ReadonlyBehaviorType } from "@antelopejs/interface-dms/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

RouteEndpoint role
GetRead one row.
ListPaginated list with search, filters and sorting.
SelectList with the select pluck mode, used by relation pickers.
CountRow count for the current query.
CountBatchOne count per query, in a single POST /count/batch (tab, view and group counters).
SummaryGET /summary?ids=…: the footer summaries a table view declared, over every row the request's filters list.
NewCreate a row.
EditUpdate a row.
DeleteDelete rows.
ArchiveSet the archive field on rows.
RestoreClear the archive field on rows.
ExportRoutesA record with exportStart (/export/start), exportStatus (/export/status/:exportId) and exportDownload (/export/download/:exportId).
AllEvery 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 publish the mutation to the table's realtime topic, 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.

OptionTypeDescription
namestringColumn header, and the label of the generated form field. Required.
typeDataTypeData type instance. Required.
descriptionstringDescription carried onto the generated form field.
ordernumberSort key among columns; missing values sort as 0.
isVisiblebooleanWhether the column is shown by default. false hides it until the user picks it in the column menu.
defaultValueDefaultValueDefault value for the column and the generated form field.
readonlyBehaviorReadonlyBehaviorPer-mode behaviour for a read-only field; see Shared Types.
validatebooleanSet to false to skip registering the validator.
filterablebooleanRegister the type's filter and allow column filtering.
inputComponentComponentOverrides the input component the data type supplies.
groupstringIdentifier of the ColumnGroup this column belongs to in the generated forms.
cellWrapbooleanWrap the grid cell without a line limit.
sizenumberDefault width of the grid column in px (150 when omitted).
displayColumnDisplayA display class instance (new DefaultDisplays.IdentityDisplay({ … }), or a module's own @RegisterDisplay("<module>:<id>") class) the grid cells (and expanded-row fields) render with, its options typed by the class. type keeps driving forms, filters, validation and exports; the formatter also receives the row. Serialized as { type, options? }.

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/interface-dms/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/interface-dms/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/interface-dms/page";
import { TableView } from "@antelopejs/interface-dms/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

OptionTypeDescription
captionstringTable caption; also the component's display name, defaulting to "TableView".
layoutTableViewLayout"full" (default): the dashboard grid; "compact": the list of a settings page (tabs, open search, quick filters, the footer's page size picker; no caption, refresh, ⋯ menu nor column menus). Either way, search shows only with @Searchable fields, filters only with a filterable column, the caption only when caption is set.
searchPlaceholderstringPlaceholder of the search field, also its accessible name.
searchShortcutstringKey opening and focusing the search, in Nuxt UI defineShortcuts notation ("/", "shift_f"): "shift_f" (⇧F) by default. It does not fire while the user types in a field.
quickFiltersTableViewQuickFilter[]{ field, label?, icon?, allLabel?, mode? } dropdown filters over a filterable column's values, mode a TableViewQuickFilterMode. The picked value is the column's own filter: kept with the table state, counted, cleared by "Clear filters"; in the compact layout the button reads "Role: Admin" with a clear button. Unknown or non-filterable columns throw.
pageSizenumberRows per page while the user picked none (default 10), 1 to 50: registration throws above. A size other than 10, 25 and 50 joins them in the table's page size picker.
paginationTableViewPaginationMode"pages" (default): a pager; "loadMore": a "Load more" button; "infinite": the next page loads as the footer scrolls into view. The last two ask for every page loaded so far, from the first row, so a refresh re-reads them.
footerTableViewFooterOptions{ countLabel?, hint?, summary?, legend? }: the footer's row count ($ key pluralized on { count }) and hint; summary figures (TableViewFooterSummary: label, field?, op: "sum" | "count", where? row rule, format? TableViewSummaryFormat) computed by the Summary route over every listed row; legend, a SelectType column. Serialized as TableViewFooterSerialized: each summary gets an id, its where stays server-side (TableViewMeta.footerSummary(id)). A sum without field, an unknown column or a legend that is no select column throws.
emptyStatesTableViewEmptyStates{ firstRun?, filtered?, error? }, each a TableViewEmptyState (title, description?, icon?, actions?: BlockLinkAction[], component?) the empty body of the grid and the cards display shows instead of the built-in words, texts — keys and literals alike — receiving { search }. A component receives state, search, refresh(), and clearSearch() / clearFilters() while there is a search or a filter chip to clear.
expandableTableViewExpandableOptionsExpandable rows, a union: { fields, fieldsLabel? } (column keys or { key, label? }, each rendered by its column's data type) or { component } (receives TableViewExpandedRowProps: row, rowId, columns, labelKey, actions, open and refresh, and draws the whole band), plus defaultExpanded ("none" | "first" | "all"), single and lazyLoad (read the row from get when it opens). Fields must name declared columns; both or neither throws. Serialized as TableViewExpandableSerialized.
rowIdKeystringField holding the row id. Defaults to DEFAULT_ROW_ID_FIELD.
labelKeystringField naming a row: it follows the drawer or modal title ("Edit task · Write the docs") and ends the breadcrumb of the edit and details pages (set on their forms as labelKey).
rowActionsTableViewRowActionOptions<T>Built-in and custom row actions.
columnsTableViewColumnsOptionsThe controller's columns as this table view shows them: hidden lists the ones it opens hidden, as isVisible: false does on the @Column, for this table view only (they stay in the column menu, filterable and in the forms). A name the controller has no column for throws at registration.
customButtonsCustomButton[]Toolbar buttons; see Shared Types.
formContainerFormContainerWhere the new / edit / view forms open.
archiveModebooleanReplace delete with archive/restore. Requires @ArchiveField.
defaultFiltersArray<{ 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, and the one a cleared sort goes back to.
pinnedRowsRowActionRule<T>Rows listed first whatever the sort, in the sort's order among themselves: within the list the route answers (a source without paginate answers every row, so its pinned rows open the first page; a paging route's open their page). The grouped display pins nothing; registration throws beside reorder.
queryParamFiltersQueryParamFiltersMaps URL query parameters onto hidden filters.
routeParamFiltersRouteParamFiltersMaps DMS route parameters onto hidden filters.
strictRuleValidationbooleanReject the whole request when any row fails rule validation, instead of processing only the eligible rows.
tabsTableViewTab[]Tabs above the table, each applying a hidden filter on one column (filter optional). A tab may link to another page instead (to: page controller or path, countFrom: data controller or location for its counter); a tab with id "all" replaces the implicit one; permission (an action of the table) or permissionId (an absolute id) gates it; navBadge (true, or { tone }) publishes its counter as the page's nav badge. Serialized as TableViewTabSerialized (to resolved per request, toPage).
realtimebooleanSet to false to disable realtime broadcast and presence for this resource. Realtime is on by default.
realtimeTopicstring | string[]Topics the backend publishes on when rows change outside the table's routes: the table reads its rows again on each event. Registered against the page from onCreated; also offered by TableView.fromSource.
liveHoldboolean | TableViewLiveHoldOptionsOn a page declared with live, keeps the rows while the page is paused and shows a "N new · Show" strip; Show reads them once without leaving the pause. { whileRowOpen?, whileScrolled?, whileSearching?, whileSorted? } (all default false) also holds the table alone, the page staying live, while a row is open in the side panel, its first row is scrolled out of view, the user's search narrows the rows or they are sorted otherwise than by defaultSort; the strip then names the reason ("Table on hold · search active"). missedLabel words the strip's count, given {count} ("12 new requests"); while the page is paused the strip adds the page's setMissedDetail breakdown. A TableView.fromSource route receives liveHeldAt (ISO 8601) on every request made while held. Also offered by TableView.fromSource.
reorderTableViewReorderOptions{ field }: rows ordered by hand with a drag handle, on the grid and on the cards display. field must be a @Sortable(), writable NumberType column, and the edit action on: registration throws otherwise. The rows are listed sorted on it, and a move saves only the rows whose value changed.
kanbanKanbanOptionsEnable the kanban display.
groupedGroupedOptions{ groupByField, by?: "value" | "day" | "week", collapsible?, count? }: offer the grouped display, the grid's columns under a header row per group. groupByField must be a @Sortable() column, a DateType one to group by day or week.
viewsTableViewViewsOptions{ items: TableViewView[], layout?: "tabs" | "strip" | "menu", defaultView?, userViews? }. A TableViewView is a TableViewViewState (filters, search, sort, columns: { visible?, hidden?, order? }, display, density) with id, label, icon, tone, dot, count, permission (an action of the table by name, or an Action) and permissionId (an absolute permission id, which wins); a view the caller fails it for is left out when the layout is served. Filter values may hold {{user.id}}, {{now}} and {{now±Nd}}, resolved by the server. Serialized as TableViewViewsSerialized (no permission nor permissionId).
cardTableViewCardOptionsThe card the kanban and cards displays draw: fields it shows, or a component drawing it whole.
displaysTableViewDisplayOption[]The displays offered, in order: a built-in id ("table", "cards") without a component, or a module display named <module>:<id>; registration throws otherwise. Left out, the built-in table alone; listed, exactly these, so the grid is kept by listing { id: "table" }.
defaultDisplaystringDisplay shown when the viewer has no stored preference. Defaults to "table".
guardsTableViewGuards<T>Server-side guards run before mutating actions.
bypassTenantAccessGatebooleanSkip the tenant access gate on every data route of this table view.

Tables over a module's route

TableView.fromSource(options: TableViewSourceOptions): ComponentBuilder<TableViewOptionsSerialized>;

A read-only table view with no data controller (tableViewFromSource). TableViewSourceOptions takes columns (a record of TableViewSourceColumn, a @Column's options plus sortable), fetchUrl (its {{params.X}} / {{query.X}} tokens are filled from the page URL, percent-encoded; the table and its tab counters list again when they change and request nothing while one has no value), fetchRowUrl? (a route answering one row by id, {{row.id}} its percent-encoded id, with the same tokens: a deep link to a row the table does not list opens through it; serialized on TableViewSourceSerialized), rowIdKey?, periodScope? (the id of a PeriodSelector: every request carries its from, to, preset and comparison, the table lists again when the period changes, and nothing is requested until the selector is on the page), capabilities? (TableViewSourceCapabilities: search, sort, paginate, filter), queryParamFilters? (URL query parameters sent to the route as hidden filter_<field> filters), custom row actions and buttons only, and the presentation options of a table view (caption, layout, pagination, tabs, quickFilters, displays, card, emptyStates, footer texts and legend…). The route answers { results, total } and is sent the part of the list query its capabilities name, nothing else; the browser does the rest over the rows it answered, except that a paging route's rows are neither searched nor sorted by the browser. Registration runs the checks of TableView() that apply (page size, display ids, tab targets) and throws when tabs, quick filters or queryParamFilters lack capabilities.filter, or when a key they, labelKey, card.fields or expandable name is not a column, or when expandable sets lazyLoad (a source has no get route), or when fetchRowUrl lacks {{row.id}}. There is no export, realtime nor footer summary.

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"), KANBAN_DISPLAY_ID ("kanban"), CARDS_DISPLAY_ID ("cards") and GROUPED_DISPLAY_ID ("grouped") are exported constants for the reserved built-in display ids. TableViewOptionsSerialized is the transported shape: customButtons, rowActions, displays become their serialized counterparts, kanban and grouped are folded into displays, and two fields are added — enableTableExport: boolean and formComponents, a record of the serialized new / edit / view forms. When a page is served, each table view on it also receives tableId (its key in the page) and isSoleTableView, which decide the URL keys opening its views and tabs (?<tableId>.view=, or ?view= for a page's only table view).

tableViewLink(page, tableId, { view?, tab? }) returns the dashboard path of page opening one of its table views on a view or a tab (/workspaces?content.view=past-due); it throws for a class that is no page controller. TABLE_VIEW_VIEW_QUERY_KEY ("view") and TABLE_VIEW_TAB_QUERY_KEY ("tab") are the keys it uses.

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 card.fields entry that is not a column, a grouped.groupByField that is not a sortable column (or not a date one, by day or week), a defaultDisplay that no declared display provides, displays: [] with neither kanban nor grouped (no display at all), or views that repeat an id, filter on a column that is not filterable, sort on one that is not sortable, name an unknown column, open a display the table does not offer, or default to an undeclared view.

Row Actions

TableViewRowActionOptions<T> configures the row menu. Every built-in action takes true, false, or a RowActionConfig<T> carrying a rule.

OptionTypeDescription
addboolean | RowActionConfig<T>Create a row.
editboolean | RowActionConfig<T>Edit a row.
detailsboolean | RowActionConfig<T>Open the read-only view.
duplicateboolean | RowActionConfig<T>Open the new form pre-filled from the row.
deleteboolean | RowActionConfig<T>Delete a row.
archiveboolean | RowActionConfig<T>Archive a row.
restoreboolean | RowActionConfig<T>Restore an archived row.
copyLinkboolean | RowActionConfig<T>Copy a link to the row.
hasSelectionbooleanEnable row selection.
customCustomRowAction<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/workspace/members/{{row._id}}/validate-email",
          method: "POST",
          successMessage: "Email validated",
        },
        rule: { field: "isValidated", notEquals: true },
      },
    ],
  },
});

A double-click on a row opens edit, or details when edit is off — a single click on a table declaring rowClick: "single" (TableViewRowClick), which leaves a click on a control of the row (a button, a link, a checkbox) or one ending a text selection to them. A custom action flagged isDefault takes that slot instead — the first flagged action whose rule accepts the row wins, and the click opens its ActionTarget like the menu entry does. This is how a table whose detail view is a custom drawer keeps the row affordance: without a click target the row is inert and no longer lights up on hover.

custom: [
  {
    label: "Details",
    icon: "i-ph-info",
    isDefault: true,
    target: { type: "drawer", component: CustomComponent("SendDetailDrawer"), title: "Details" },
  },
],

Rules are enforced on both sides. A data controller has one writing table view — a TableView enabling at least one write action (add, duplicate, edit, delete, archive or restore) that the controller offers — and its rowActions (archive-mode defaults included) become the controller's controllerRowActionRules, along with its rowIdKey and strictRuleValidation. The edit, delete, archive and restore routes validate the affected ids against those rules before running. Rows that fail the rule are dropped from the operation; with strictRuleValidation the whole request is rejected instead.

A second writing table view over the same controller fails the registration of its page with a WritingTableViewConflictError (a PageDeclarationConflictError, rethrown even from a nested component) naming both table views. Read-only table views — every write action set to false, no archiveMode — may share the controller: they open no new / edit form and leave its rules and options alone. A page re-registering (a hot reload) re-claims the controller; a page that went away releases it. To give the same data a second writing screen, derive a controller (see Show the same data in two writing screens).

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.

Several pages may each mount their own TableView over one data controller, and they all share its data routes. A read action (list, select, view, export, viewArchived) accepts the permission from any table view a page mounts on that controller; a write action (add, edit, delete, archive, restore) accepts only the writing table view's, so a user granted add on a read-only table view cannot create rows. A read-only table view still declares the write actions the controller offers: while no table view writes through the controller, they keep its write routes behind permission ids nobody is granted by default. The inline add button of a relation field pointing at the controller follows the same rule (addPermissionIds).

A new or edit ResourceForm block over the controller submits to the same routes, so the native files staged from its fields are accepted on save as well, alongside those of the writing table view. It adds no permission of its own to the union: the routes still require the action's permission from a table view of the controller when one is mounted, exactly as for the block's other fields. The new and edit forms a page-mode table view mounts on its form sub-pages count the same way: each is a component of its own sub-page, so the files staged from it are accepted on save, under the same table view permissions.

TableViewRowActionOptionsSerialized is the transported shape: rules and flags untyped, custom as CustomRowActionSerialized[], plus a showArchived?: boolean field the builder fills from the viewArchived permission.

Row action extras

TableViewRowActionOptions also takes menuLabel: the accessible name of a row's ⋯ menu button, its {field} braces filled from the row (a $ key's translation receives the row's fields), "Actions for

RowActionConfig (built-in actions) also takes label, icon and confirm (an ActionConfirm: a ConfirmDialog, or { from }, a URL answering one for the row — on delete, archive and restore, for a single row); BulkRowActionConfig (delete, archive, restore) adds successMessage. CustomRowAction also takes confirm, permission (ButtonPermission: an action of the table by name, or an Action), permissionId (an absolute permission id, which wins over permission), color, variant, showLabel, bulk (true or { allMatching: true }: offered on the selection bar, see below) and deepLink (a drawer, modal or side panel it opens is kept in the URL as ?record=<id> — by default for a side panel, which false opts out of —, ?<tableId>.record=<id> on a page with several table views). An action the caller fails the permission for is stripped per request like a custom button; it is only hidden, so the route its target calls must check the permission itself.

A bulk custom action sends the selected ids as a repeated ids query parameter, or allMatching=true with the table's filters, search and archive view after "Select all N matching". resolveBulkRowIds(this, ctx, user), called from a route of the table's data controller, answers the ids the action covers: the selected ones, or every matching row read like the list reads them, refusing more than MAX_BULK_MATCHING_ROWS (413). Either way the caller needs the table's list permission (403 otherwise). BULK_ALL_MATCHING_KEY is the query flag. The api action target also takes body (the confirmation's field values merged in) and copy (response field copied to the clipboard). CustomButton takes placement ("toolbar", the default, or "header": drawn in the page header, which the server adds it to); rowActions.add takes it too (AddRowActionConfig).

When several table views share a data controller, each read action of its routes is guarded by the permission ids of every mounted table view, each write action by the writing table view's (TableViewMeta.actionPermissionIds(action); any one suffices). TableViewMeta.componentBuilders lists them, writingComponentBuilders the ones that write.

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/interface-dms/request-tenant";
import { GetModel } from "@antelopejs/interface-database-decorators";
import { TenantMemberModel } from "@antelopejs/interface-dms/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, values }: values is the answer of the action's confirmation fields, sent as the request body, and is unset when the request carried none (see Confirmations). 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. A QueryParamFilter also takes visible?: boolean and label?: string: a visible one shows as a removable chip of the filters row while the URL applies it. 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. An active queryParamFilters filter makes the table read as filtered (its empty body is the filtered state, without a clear action), and a change of its value in the URL lists from page 1.

static table = TableView(invoiceDataAPI, {
  routeParamFilters: { id: { field: "customerId" } },
  defaultSort: { field: "createdAt", desc: true },
  tabs: [
    { id: "open", label: "Open", filter: { accessorKey: "status", value: "open", mode: "is" } },
    { id: "paid", label: "Paid", filter: { accessorKey: "status", value: "paid", mode: "is" } },
  ],
});

TableViewTab is { id, label, filter?: TableViewTabFilter, icon?, textColor?: ColorValue, iconColor?: ColorValue } plus the link fields (to, countFrom), the gate (permission, permissionId) and navBadge?: boolean | TableViewTabNavBadge ({ tone?: Tone }), and TableViewTabFilter is { accessorKey: string; value?: string; mode: string }. A tab gives a filter or a to, never both (registration throws). permission is relative to the table view's permission id ("list" → <table permission id>.list) or an Action; permissionId is absolute and wins; a link tab to a page controller follows that page's own permission without either. In the page editor, the tab's permission picker sets permissionId, and permission is the advanced "Action" text field.

navBadge: true publishes the tab's counter as the navigation badge of the page it stands for: this table's page for a filter tab, the linked page for a link tab — which then needs a page controller to and a data controller countFrom; a path or a location logs a warning and publishes nothing. The server counts it on each page visit (the page payload's navBadges, never the cached site layout), with the caller's own list permission on that table, for the pages the caller can open only; the badge shows in the main and the settings navigation. A count of zero shows no badge, a page's static MenuOptions.badge wins over it, and while the table is on screen its own counter keeps it fresh. TableView.fromSource tables do not support it. navBadge: { tone } draws the badge in that tone instead of neutral; counts landing on one page add up, in the strongest tone. Declaring at least one tab also surfaces an implicit "all" tab that applies no filter.

displays declares alternative renderings of the same data:

FieldTypeDescription
idstringDisplay identifier. Required.
optionsRecord<string, unknown>Options handed to the display.
componentComponentFrontend component, resolved by name from the global registry.
selfManagedDatabooleanThe display fetches its own data.
capabilitiesTableViewDisplayCapabilitiesWhich 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, unless displays already places { id: "kanban" }, which then receives them.

OptionTypeDescription
groupByFieldstringColumn the cards are grouped by. Must be a non-multiple SelectType, a BooleanType or a StatusType column. Required.
draggablebooleanAllow dragging a card to another column to change the group field. Defaults to true.
columnMaxHeightstringCSS max-height of a column's card list before it scrolls. Defaults to "60vh".

KanbanOptionsSerialized is KanbanOptions plus the serialized card (TableViewCardOptionsSerialized).

card (TableViewCardOptions: { fields?: string[]; component?: Component }) is the card the kanban and cards displays draw for each row; it joins the options of both display entries. The component receives the same props in both: row, rowId, columns, labelKey, actions, selected, select(value?), open(), on the kanban groupValue, and on the cards display of a table ordered by hand reorder ({ enabled, dragging, handle }, the card's move handle).

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 record the viewer's presence on that row — 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"; pages?: FormContainerPages<FormContainerPageTexts> }
  | { type: "modal"; size?: ModalSize; pages?: FormContainerPages<FormContainerPageTexts> }
  | { type: "page"; pages?: FormContainerPages<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, each guarded by the matching action's permission.

The sub-pages are named after the table view, not after the page: their default slugs are <key>/new, <key>/:id/edit and <key>/:id/view, where <key> is the key the table view is mounted under — static invoices = TableView(...) on /billing gives /billing/invoices/new. It is the simple key even for a table view nested deep in a layout (.child("invoiceTable", TableView(...)) gives invoiceTable, not layout/body/main/invoiceTable), so the URL stays readable and survives the layout being reorganised. That is what lets one page carry several table views, nested or not, each with form pages of its own. The key does not depend on the page declaring a permission.id of its own. A table view instance belongs to the first page that mounts it: its form routes cannot be keyed on a second page mounting the same instance in page mode, which is an error, so give each page its own TableView(...).

pages ({ new?, edit?, details? }) titles the forms in every container: a FormContainerPageTexts entry is { displayName?, description? } ($-prefixed: i18n keys), falling back to "New entry", "Edit entry" and "Entry details". In page mode an entry is a FormContainerPageConfig, which also overrides the sub-page:

OptionTypeDescription
urlSlugstringSlug of the sub-page, relative to the page — no key segment is inserted, so a declared slug is full control over the URL. The edit and details slugs must contain :id, otherwise TableView throws. A slug starting with / addresses a page of its own; leading .. segments each drop one segment of the carrying page.
displayNamestringForm and sub-page title.
descriptionstringForm and sub-page description.
customPagebooleanDo not auto-create the sub-page — you registered your own page at that URL. The slug is still used for navigation.

Whichever slugs apply, the builder resolves them against the slug of the page carrying the table view and serializes the three URLs as formPages ({ new, edit, view }), :id placeholder included. The frontend navigates to those URLs as they stand — substituting the row id, and the current route params for the placeholders the carrying page contributes — rather than rebuilding them from the browser path. A customPage entry registers no sub-page and still gets its URL serialized: it is navigated to all the same.

A page below a :id page therefore carries two :id placeholders (/workspaces/:id/invoiceTable/:id/view). The row id takes the last one and the route params of the carrying page fill the rest, so the form reads the row id from params.id — the same thing it means on a flat /customers/:id/edit, which is what the generated fetchUrl and submitUrl count on.

The id of the page above is not lost to the repetition: when a route repeats a placeholder name, every occurrence is also exposed under <name>:<n>, numbered from 1 in the order they appear in the pattern. On /workspaces/:id/invoiceTable/:id/view a form reads {{params.id}} or {{params.id:2}} for the row and {{params.id:1}} for the workspace carrying it. A name that occurs once gets no indexed key — {{params.id}} is all there is to read on every single-placeholder route.

Two page-mode table views on one page whose form URLs resolve to the same path — the same explicit urlSlug, or the same key under two different parents — fail the registration of the page, naming both table views and the path. The check covers the page's own components and the table views a page extension contributes later. Give one of them a urlSlug of its own to resolve it.

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 two realtime topics for its page (and for each generated sub-page), and its routes publish every create, update and delete on the first and track who has a row open on the second. Both are named after the controller's location:

import {
  tableViewPresenceTopic,
  tableViewRowTopic,
} from "@antelopejs/interface-dms/base/table-view";

tableViewRowTopic("/api/orders"); // "tableview:row:/api/orders"
tableViewPresenceTopic("/api/orders"); // "tableview:presence:/api/orders"

The topics are registered with RegisterPageTopic from the table view's onCreated, so they belong to the module whose page it is, as described in Realtime. A module that publishes on a table's row topic itself — a background job updating rows, for instance — builds the name with tableViewRowTopic rather than spelling out the prefix.

Two functions let other modules observe those mutations:

import {
  registerRealtimeMutationListener,
  unregisterRealtimeMutationListener,
  type RealtimeMutationContext,
} from "@antelopejs/interface-dms/base/table-view";

const listener = (context: RealtimeMutationContext) => {
  // context.controllerLocation, context.eventType, context.ids, …
};

registerRealtimeMutationListener(listener);

Listeners are additive side observers: they run after the DMS has published the mutation, and a listener that throws is logged, never propagated to the mutating request. A listener is released with the module that registered it, including when that module reloads in development; unregisterRealtimeMutationListener is for a module that stops listening while it stays loaded.

TypeShape
RealtimeMutationEventType"created" | "updated" | "deleted"
RealtimeMutationContextcontrollerLocation, rowIdKey, eventType, ids: string[], sessionId?, actor?: RealtimePresenceActor
RealtimePresenceActorid, displayName?, avatarUrl?
RealtimePresenceContextcontrollerLocation, rowIdKey, sessionId, rowId, actor

The routes reach the DMS through two interface functions it implements, internal.PublishMutation and internal.AcquirePresence. They are internal: nothing outside the DMS implements them, and the core binds them to each generation of the DMS module, so a mutation made while the DMS reloads waits for the next generation instead of reaching the one being torn down.

Metadata

TableViewMeta is the metadata object the decorators write to and the builder reads from. Retrieve it with GetMetadata(controller, TableViewMeta).

MemberDescription
columnsRecord<string, ColumnOptions> keyed by field name.
groupsRecord<string, ColumnGroupConfig> keyed by group id.
optionsThe TableViewOptions of the writing TableView, or of the last one built while none writes.
archiveFieldField name recorded by @ArchiveField.
controllerRowActionRulesThe rules the write routes enforce: the writing table view's rowActions, archive-mode defaults included.
controllerGuardsGuards promoted to controller-wide guards.
componentBuildersEvery live TableView builder built on the controller, in build order.
writingComponentBuildersThe live builders of componentBuilders that write through the data routes.
actionPermissionIds(actionId)The permission id the action carries on each mounted table view of the controller — on the writing one only for a write action.
addComponentBuilder(builder, access?)Writer the builder calls to record itself, with its TableViewAccess ("read" or "write", the default). A custom component standing in for a table view (the roles editor) records itself the same way.
resourceFormBuildersEvery live new / edit form built over the controller, in build order: the ResourceForm blocks and the forms each table view opens.
addResourceFormBuilderWriter the form builder calls to record a form that submits to the controller.
writingComponentsEvery live component that submits to the controller's write routes: its writing table view and its new / edit forms, including the ones a page-mode table view mounts on its form sub-pages. A native file staged by any of them is accepted on save; one staged by any other component is refused.
inherit(parent)Called by GetMetadata for a derived controller: copies the parent's columns (each with its own copy of its data type), groups, options and archiveField, never its table views or forms.
configGetter 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, setControllerGuardsWriters 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.

FunctionRole
listWithSearchRuns the list query with search, filters, sorting and pagination; returns { results, total, offset, limit }.
countWithSearchSame query, count only; returns { total }.
startExportQueues an export job; returns { jobId, format, extension }.
getExportStatusReturns { status, progress, error? } for a job.
downloadExportStreams a completed job's file to the response.
archiveRowsSets the archive field on the given ids; returns { success, archivedCount }.
restoreRowsClears it; returns { success, restoredCount }.
validateRowsAgainstRuleSplits ids into { eligibleIds, rejectedIds } against a RowActionRule.
fetchRowForGuardLoads one row by id field, for the edit guard and the file-cleanup passes.

Events and Functions

Namespace memberValue
TableViewEvents.ROW_CLICK"DmsComponent.TableView.RowClick"
TableViewEvents.ROW_SELECT"DmsComponent.TableView.RowSelect"
TableViewEvents.ROW_DELETE"DmsComponent.TableView.RowDelete"
TableViewEvents.ROW_ADD"DmsComponent.TableView.RowAdd"
TableViewEvents.ROW_EDIT"DmsComponent.TableView.RowEdit"
TableViewEvents.FILTER_CHANGE"DmsComponent.TableView.FilterChange"
TableViewEvents.SORT_CHANGE"DmsComponent.TableView.SortChange"
TableViewEvents.EXPORT"DmsComponent.TableView.Export"
TableViewFunctions.CUSTOM_PAGE_FORM_SUCCESS"DmsComponent.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 handler resolves {{params.X}} / {{query.X}} tokens in url against the route being left before navigating; the generated forms rely on it to send the user back to a page whose slug carries parameters: each placeholder of the page's slug becomes the token reading it on the form route (/workspaces/:id becomes /workspaces/{{params.id:1}} from /workspaces/:id/invoiceTable/:id/edit, /workspaces/{{params.id}} from /workspaces/:id/invoiceTable/new). A slug without parameters is used as is. 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