TableView displays
A TableView's toolbar (Tables) — search, filters, sort, tabs, pagination, selection, row actions — drives one shared list query; the body is just one way to paint the result. A display swaps that body. The grid is the default, kanban ships as a built-in alternative, and your own display (cards, calendar, gallery, map…) inherits the entire toolbar for free.
A display is two declarations joined by an id:
- a frontend registration — label, icon, body component, availability — from a plugin in your frontend layer;
- a backend opt-in — the table view's config offers the display, picks the default, and declares its data behaviour and chrome.
Register the display type
Register once, globally, from a plugin, under an id of the form <module>:<id> ("saas:plan-cards", "automation:timeline"). The built-in ids — table, kanban, cards and grouped — are reserved, and an id is registered once: registerTableViewDisplay throws for a reserved id, an id without its module prefix, or an id another registration already claimed.
import MyDisplay from "../components/MyDisplay.vue";
export default defineDmsPlugin(() => {
registerTableViewDisplay({
id: "crm:deal-board",
label: "Board",
icon: "i-ph-cards",
order: 30,
component: MyDisplay,
});
});
A TableViewDisplay:
| Field | Type | Notes |
|---|---|---|
id | string | <module>:<id>. Stable id; matches displays[].id, defaultDisplay, and the persisted user preference. |
label | string | i18n key or literal label shown in the view switcher. |
icon | string | Icon for the switcher. |
component? | Component | ComponentInfo | Body renderer — a component, or a ComponentInfo resolved by name. undefined means "render the built-in grid". |
order? | number | Switcher ordering, lower first. Defaults to 100. |
isAvailable? | (ctx) => boolean | Hide the display for a table it cannot render; ctx is { columns, options? }. Kanban uses it to require a group-by column. |
Register from a universal plugin (not .client): the registry is rebuilt by the plugin on the server and in the browser — it is never serialized, since it carries callables — so the server render already draws the view switcher and hydration finds it in place. A display registered from a .client plugin still works, but only joins the switcher after hydration, which shifts the toolbar.
The display context
The body component receives one prop, context (a TableViewDisplayContext). Paint items and the display inherits search, filters, sort, and pagination automatically — they all feed the same shared query.
const props = defineProps<{ context: TableViewDisplayContext }>();
| Field | Type | Purpose |
|---|---|---|
items | T[] | Current page rows from the shared list query — every row loaded so far under loadMore or infinite pagination. Empty for self-managed displays. |
columns | TableViewColumn[] | Full column metadata (types, labels, accessor keys). |
loading | boolean | Shared query is in flight: its first read or any re-read. |
firstPageLoading | boolean | No answer has arrived yet: draw a skeleton, not the empty state. A re-read keeps the rows, or the empty state, on screen; swapping either for a skeleton flashes. |
selection | TableViewDisplaySelection | { ids, isSelected(id), toggle(id, value?), toggleAll(value?), clear() } — one selection state shared with the grid. |
pagination | TableViewDisplayPagination | { pageIndex, pageSize, total, setPage(i), setPageSize(n), mode, hasMore, loadMore() } — mutations re-run the shared query. mode is the table's pagination mode; under loadMore and infinite, call loadMore() while hasMore. |
actions | TableViewDisplayActions<T> | canAdd/canEdit/canDelete/canDetails flags, per-row canEditRow(item) / canDeleteRow(item), plus add, edit, delete(ids, rows?) (a display hands the rows it draws, which a confirmation naming the row reads), duplicate(id), details, open(item), custom(action, item), and customFor(item), the custom row actions the item offers. |
presenceByRow | TableViewPresenceMap | Realtime presence keyed by row id — for "X is editing this" affordances. |
rowIdKey | string | Field used as the row id (default _id). |
labelKey? | string | Field used as an item's human title, if configured. |
location | string | Data API location — for self-managed fetching. |
query | Record<string, unknown> | Active filters/search/sort (no limit/offset) — for self-managed fetching. |
componentId? / pageId? | string | Stable identifiers, e.g. for per-instance fetch keys. |
options? | Record<string, unknown> | Per-instance options declared for this display in the backend config. |
refresh() | () => Promise<void> | void | Re-fetch the table view. |
setShownRows(rows) | (rows: T[]) => void | Self-managed displays: hand the rows shown, in reading order, so a row opened from the display steps through them in its drawer (below). |
reorder? | TableViewDisplayReorder | Rows ordered by hand (backend reorder): { enabled, move(from, to) }, indexes into items, saved like a move on the grid. Set on the grid and the cards display, for a caller who may edit; enabled is off while the rows are narrowed (see Rows ordered by hand). |
table? | Table<T> | Escape hatch: the TanStack table instance, when rendered inside the body slot. Advanced displays only. |
The per-row predicates matter: actions.canEditRow(item) and actions.canDeleteRow(item) evaluate conditional rules, so a display can gate an affordance on the individual item rather than a blanket flag. actions.customFor(item) does the same for the custom row actions: it lists those whose rule accepts the item, so a card drawing its own menu offers what the grid's menu would. Give an action an id to find a given one among them (a card placing "Stop selling" on its own button).
items and inherit everything. A self-managed display (selfManagedData: true in the backend config) skips the shared list query — items arrives empty — and fetches its own data from context.location using context.query. Expose a refresh() method so the table view can re-sync it on realtime updates and manual refresh, and call context.setShownRows(rows) whenever the rows it shows change, in the order the user reads them: a row opened from the display then steps through them in its drawer or side panel ("3 of 12", previous / next, J and K). Without it, the row opens without steps. The built-in kanban works this way and hands its cards column by column, top to bottom in each.Offer it from the table view
A registered display type is inert until a table view offers it. A table view without displays offers the table display alone; one with displays offers exactly the displays it lists, so list { id: "table" } among them to keep the grid. defaultDisplay chooses what shows when the user has no saved preference (defaults to the first display offered).
static table = TableView(taskDataAPI, {
caption: "Tasks",
labelKey: "name",
rowActions: { add: true, edit: true, delete: true, hasSelection: true },
displays: [
{ id: "crm:deal-board", component: CustomComponent("MyDisplay") },
{ id: "table" },
],
});
Each entry is a TableViewDisplayOption. Registration throws for an id that is neither a built-in one nor <module>:<id>, and for a built-in id given a component of its own (a display of your own takes your own id):
| Field | Type | Notes |
|---|---|---|
id | string | A built-in display id ("cards"), or a module display's <module>:<id>. |
options? | Record<string, unknown> | Per-instance options forwarded to the body via context.options. |
component? | Component | SSR-safe component reference (CustomComponent("…")) — provide it so the body renders during SSR (see below). |
selfManagedData? | boolean | When true, the shared list query is skipped for this display. |
capabilities? | TableViewDisplayCapabilities | Chrome shown while this display is active (see below). A capability turned off also stops its persisted state from narrowing the shared query. |
Capabilities and their defaults:
| Capability | Default | Controls |
|---|---|---|
columnManagement | false | Column visibility / pin / size / order (grid-only) |
filters | true | Filters row and filter trigger |
search | true | Global search box |
sorting | true | Sort-by-field menu |
tabs | true | Filter tabs |
header | true | The table's own chrome around the display (caption, toolbar, tabs, filters row, bulk bar); a display drawing a complete interface of its own turns it off |
Behaviour lives on the backend config — not the client registry — because the server renders first: selfManagedData decides whether to run the shared list query, and capabilities decides which toolbar controls render. Deciding those client-side would differ between server and client and produce hydration mismatches. For the same reason, reference the body component from displays[].component too, so it renders during SSR; the client registry confirms the same component after hydration.
The available displays are the intersection of three sets: the client registry, the ids the backend offers, and each display's isAvailable rule. The active one is restored from the user's persisted preference, with two guards: a stale persisted id is clamped back to the offered set, and a display that turns out unavailable after hydration degrades to the grid.
Built-in displays
Four displays ship out of the box:
table(order: 10) — the grid. It has nocomponent, which is the signal to render the table view's own body, and, withgrouped, one of the two displays withcolumnManagementon.kanban(order: 20) — itsisAvailablerequires an eligible group-by column (single-select, boolean, or status), and its defaults setselfManagedData: trueandtabs: false.cards(order: 30) — a grid of cards over the shared list query, drawn from the table view'scardoption like the kanban's (see Cards).grouped(order: 15) — the grid itself, its rows under a header per group (the table view'sgroupedoption). Like the grid it has nocomponent; its defaults keepcolumnManagementon and turnsortingoff, since the rows are listed sorted on the grouped column.
These built-in defaults merge in per id: offering a built-in display doesn't restate them, and a partial override merges key-by-key — overriding one kanban capability keeps the rest.
Example: a cards display
A complete custom card display is three files: the client registration, the body component, and the backend opt-in.
1. Register the type from a plugin in your frontend layer:
import TaskCardsDisplay from "../components/TaskCardsDisplay.vue";
export default defineDmsPlugin(() => {
registerTableViewDisplay({
id: "playground:task-cards",
label: "Cards",
icon: "i-ph-cards",
order: 30,
component: TaskCardsDisplay,
});
});
2. The body component paints context.items and wires selection, actions, and pagination back through the context:
<script setup lang="ts">
const props = defineProps<{ context: TableViewDisplayContext }>();
const rowId = (item: Record<string, unknown>): string => String(item[props.context.rowIdKey] ?? "");
const title = (item: Record<string, unknown>): string => {
const value = props.context.labelKey ? item[props.context.labelKey] : undefined;
return value != null && value !== "" ? String(value) : rowId(item);
};
const currentPage = computed({
get: () => props.context.pagination.pageIndex + 1,
set: (page: number) => props.context.pagination.setPage(page - 1),
});
</script>
<template>
<div v-if="context.firstPageLoading" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<USkeleton v-for="n in 6" :key="n" class="h-32 w-full" />
</div>
<div v-else-if="context.items.length === 0" class="py-10 text-center text-sm">No items</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="item in context.items"
:key="rowId(item)"
class="rounded-lg border p-4"
:class="{ 'cursor-pointer': context.actions.canEdit }"
@click="context.actions.canEdit && context.actions.edit(item)"
>
<div class="flex items-center gap-2">
<UCheckbox
:model-value="context.selection.isSelected(rowId(item))"
@click.stop
@update:model-value="(v) => context.selection.toggle(rowId(item), !!v)"
/>
<p class="truncate text-sm font-semibold">{{ title(item) }}</p>
<UButton
v-if="context.actions.canDelete"
icon="i-ph-trash"
color="error"
variant="ghost"
size="xs"
@click.stop="context.actions.delete([rowId(item)], [item])"
/>
</div>
</div>
</div>
<UPagination
v-model:page="currentPage"
:total="context.pagination.total"
:items-per-page="context.pagination.pageSize"
/>
</template>
3. Opt the table in from the backend page controller, with an SSR-safe component reference:
static table = TableView(taskDataAPI, {
caption: "Tasks — Cards",
labelKey: "name",
rowActions: {
add: true,
edit: { isVisible: true },
delete: { isVisible: true },
details: true,
duplicate: true,
hasSelection: true,
},
displays: [
{ id: "table" },
{ id: "playground:task-cards", component: CustomComponent("TaskCardsDisplay") },
],
defaultDisplay: "playground:task-cards",
});
When the user types in the shared search box or flips a filter, the shared query re-runs and items updates underneath the cards — no extra wiring. To react to those changes elsewhere on the page, use the table view's component events.