Row actions
The actions a table view offers on each row, archive mode, and the server guards that run before a write.
Each row of a TableView offers actions: the built-in edit, view and delete, and the module's own. This page covers those actions, archive mode, and the guards the server runs before each write.
Row actions
rowActions enables the per-row menu. Each built-in action takes a boolean or a RowActionConfig with optional isEnabled, isVisible, and rule:
| Action | Needs route | Effect |
|---|---|---|
add | new | Toolbar Add button (opens the new form). |
edit | edit | Open the edit form. |
details | get | Open the read-only view form. |
duplicate | new | Open the new form pre-filled from the row (?duplicate=<id>). |
delete | delete | Delete the row. |
copyLink | get | Copy a deep link to the row. |
archive / restore | archive / restore | Soft-archive / restore (archive mode). |
hasSelection | None | Enable the selection checkbox column and bulk actions. |
custom | None | Array of custom actions (below). |
menuLabel | None | Accessible name of the row's ⋯ menu button (below). |
A rule hides or disables the action per row by evaluating against the row's fields. Field operators are equals, notEquals, in, notIn; combine them with and, or, not:
rowActions: {
add: true,
edit: { isEnabled: true, rule: { field: "status", notEquals: "completed" } },
delete: {
isEnabled: true,
rule: {
and: [
{ field: "status", notEquals: "completed" },
{ field: "priority", notEquals: "high" },
],
},
},
hasSelection: true,
},
Rules are enforced server-side as well as in the UI:
- A delete/edit/archive/restore request for an ineligible row is filtered out.
- A non-strict edit whose single row is ineligible returns
403. - With
strictRuleValidation: true, the whole request is rejected400. - The rules are those of the controller's writing table view. A data controller has one: a
TableViewwith at least one write action enabled (add, duplicate, edit, delete, archive or restore). Other pages may mount read-only table views over the same controller, with every write action set tofalse, but a second writing one fails its page's registration at boot. To show the same data in two writing screens, derive a controller for the second one: see Show the same data in two writing screens.
Custom row actions add menu entries that fire an ActionTarget rather than a built-in CRUD operation. The custom array below sits inside the TableView's rowActions:
custom: [
{
label: "Mark complete",
icon: "i-ph-check-circle",
target: { type: "api", url: "/api/task/complete", method: "POST",
successMessage: "Task completed" },
rule: { field: "done", equals: false },
},
],
A target's URL (page, external, download, api, exportJob) may also name the page the table is on, as a block's fetchUrl does: {{params.X}} and {{query.X}} are filled from the page URL when the action runs, a query pair whose token the page has no value for being left out, then the row's {{row.field}}. url: "/shop/order?id={{row._id}}&store={{query.store}}" opens the order in the store the reader is looking at. A confirmation's from takes the same tokens (Template tokens).
A custom action's label may name the row's fields as translation parameters, in single braces (a URL names them {{row.field}}): "Translate {name}" reads "Translate Français" on that row, and a $ key's translation receives the row's fields as parameters ("Translate {name}" in the locale file). A brace naming no field stays as written. On the selection bar (bulk) the label names several rows, so keep row fields out of a bulk action's label.
The ⋯ button opening a row's menu is named for assistive technologies "Actions for
A custom row action can also take a permission or a permissionId (like a custom button below, it is stripped for a caller without it; it only hides the action, so the route its target calls must check the permission itself), a color, and, when inline (isVisible: true), a variant and showLabel to draw a labelled button ("Resend", "Revoke") rather than an icon. In the ⋯ menu, the row's own actions (details, edit, duplicate) come first, then the custom ones, then the destructive ones (delete, archive, restore) after a separator.
A built-in action's RowActionConfig can rename and re-icon it (label, icon: "Change roles" for edit); delete, archive and restore, the actions that run on a selection too, also take a successMessage toasted once they succeeded.
Bulk actions
bulk: true also offers a custom action on the selection bar (with hasSelection): an api, exportJob, page, external or download target receives the selected ids (?ids=a&ids=b), a drawer or modal a selection prop, and its texts { count }. With bulk: { allMatching: true }, "Select all N matching" sends the table's filters instead, and the route finds the rows with resolveBulkRowIds. See Bulk actions.
Row drawers
A custom action opening a drawer or a modal on a row hands its component a navigation prop to step through the rows shown (J and K too), and deepLink: true keeps the open row in the URL (?record=<id>). A sidePanel target opens the row in a non-modal panel docked next to the table instead, the DMS drawing its header (title, "x of y", previous / next, close); with rowClick: "single", a click on a row opens it. See Row drawers.
Confirmations
Every action, built-in or custom and whatever its target, and every custom toolbar button takes one confirm option: a fixed ConfirmDialog (title, impact list, type-to-confirm, fields whose values go with the request…), or { from }, a URL answering the dialog the server words for the row. See Confirmations.
delete: { confirm: { from: "/settings/workspace/members/{{row._id}}/removal-confirm" } },
An api target may send a JSON body and copy a field of its response to the clipboard (copy: "url" for a share link).
A target can open a component in a drawer or a modal, navigate to an internal page or an external URL, run a quick action ({ type: "quickAction", id }), call an API endpoint, or run an async export job. Each ActionTarget type and its fields are in Actions & reactivity. Custom toolbar buttons (customButtons) take the same target, plus a variant and a color.
A custom toolbar button can also declare a permission: either the name of one of the table's own actions, relative to its permission id (e.g. "add" → <table permission id>.add), or an Action from another component (otherTable.getAction("add")). permissionId takes an absolute permission id instead and wins over permission. The button is stripped from the serialized options when the caller lacks the permission, as the built-in actions are. The route the button calls still needs its own guard (@AuthUserWithPermission(...)). placement: "header" draws it in the header of the page instead of the toolbar, as does rowActions: { add: { placement: "header" } } for the built-in add, whose label ("$projects.add") and icon ("i-ph-folder-plus") name and draw its button wherever it is drawn (toolbar, empty state or header), and "New entry" and a plus without them: see Header actions. Give the button an id to expose it as a quick action ({ type: "button", page, button: "<id>" }), which inherits this permission.
A custom toolbar button can also declare an availability resolver, called per request with { tenantId, user } while the page layout is filtered. When it returns { reason }, the button reaches the client disabled, with the reason (a string or a $ i18n key) in its tooltip; a quick action pressing it shows the reason instead of opening the target. Returning { hidden: true } leaves the button out for that request, as if the caller lacked its permission (a quick action pressing it then does nothing). Returning undefined leaves it enabled, and a resolver that throws is logged and leaves it enabled too. It only shapes the UI: the operation behind the button must still refuse on its own.
Archive mode
Set archiveMode: true and decorate a boolean field with @ArchiveField(); archive mode swaps delete for archive/restore.
TableViewthrows at registration ifarchiveModeis set without an@ArchiveFieldon the controller.- An
@ArchiveFieldwithoutarchiveModeis accepted: it enables the archive filter on list without swapping the actions. - Archive mode registers three permissions:
archive,restoreandviewArchived. - While the archived rows are listed, the caption counts them as "Archived: 13" ("Archives : 13" in French), a wording that agrees with no noun.
archivedCountLabelnames them instead: a$i18n key receiving{ count }and pluralized on it, or a text with{count}, such asarchivedCountLabel: "$invoices.archived_count"with"{count} facture archivée | {count} factures archivées".
It also injects default rules so the right action shows per row: edit/duplicate/archive apply only when the archive field is not true; restore/delete apply only when it is true. Your own rule on any of these is combined with the default one (both must hold): it narrows the action further, and never makes an archived row editable or a live one deletable. Turn an action off (false) to drop it. These rules are enforced server-side like any rule, and a row whose archive field was never written (created by a form that leaves it out) counts as active. @ArchiveField() makes the field filterable, listable and, unless it declares an @Access of its own, read-only, so the rows carry it for the client to evaluate the rules.
The viewArchived permission is enforced server-side: a client sending showArchived=true without it gets a hard 403, not a silent empty list. Archive and restore are bulk PUT operations over selected ids.
Guards
guards runs a server-side check before a mutating action commits. this inside a guard is the data controller instance, so @Model-bound models are reachable. Throw with assert(...) from @antelopejs/interface-api-util to abort with an HTTP status and an i18n message key:
import { assert } from "@antelopejs/interface-api-util";
guards: {
edit: (ctx, { id, body, current }) => {
assert(current.status !== "locked", 403, "$tasks.locked");
},
delete: (ctx, { ids }) => { /* … */ },
},
Guards exist for get, new, edit, delete, archive and restore, each receiving the relevant id(s) or body (and, for edit, the current row). A guard may also return a function, run once the write succeeded in the same request. That is the place for work that compares the row before and after, like telling a member what changed (AfterWrite, Types):
guards: {
edit: (ctx, { current }) => async () => {
await notifyAssigneeChanged(current);
},
},
The new and edit guards may also amend the body in place, to fill a generated number, stamp a field or drop one. The route writes the body as the guard leaves it, and checks it (mandatory fields, types) as it would the request's. No need to wrap TableViewRoutes.New:
guards: {
new: async function (ctx, { body }) {
body.number = await this.invoices.nextNumber(); // `this`: the data controller instance
},
},
A refusal that belongs to one field of the form (a name already taken, a value out of range) should say so: throw an HTTPResult whose body is { message, field }, where field is the field id, and the form shows the message under that field instead of a toast. values (optional) names the offending values of a list field (the invite form marks those addresses):
import { HTTPResult } from "@antelopejs/interface-api";
guards: {
new: async function (ctx, { body }) {
if (await this.tasks.getByTitle(body.title)) { // `this`: the data controller instance
throw new HTTPResult(409, { message: "$tasks.title_taken", field: "title" });
}
},
},
The form reads the same way the zod issues assertValidation answers with (each issue's first path segment is its field) and the data API's { code: "missing_mandatory_fields" | "invalid_field_types", fields: [...] }. A write outside a form (a card dropped on the board, a row moved by hand, a row action) toasts the same refusal naming each field by its column's label: "Loss reason is required." Anything else (a 403, a 429, a server error, a refusal naming no field the form shows) stays a toast.