Actions
Actions power the row, header, and bulk buttons. The defaults are
viewAction()/editAction()/deleteAction() (rows), createAction() (header), and
bulkDeleteAction() (bulk) — override the corresponding methods to customize.
import { action, editAction, deleteAction, textInput } from "@zerotal/admin";
static recordActions() {
return [
editAction(),
action("publish")
.label("Publish").icon("check-circle").color("success")
.requiresConfirmation("Publish this post?")
.authorize((rec) => this.can("update", rec))
.run(async (ctx) => { await ctx.record.publish(); }),
deleteAction(),
];
}
A row with more than three visible actions collapses the surplus into an overflow menu. Actions can also open a modal form:
action("assign")
.label("Assign reviewer")
.form([ select("reviewer_id").options(reviewers).required() ])
.run(async (ctx) => { await ctx.record.assign(ctx.data.reviewer_id); }),
.run(ctx) receives { resource, record?, ids?, data?, parentId?, listOptions? }
(row / header / bulk).
Action groups
A row with seven buttons is unreadable. actionGroup collapses several into one
labelled dropdown, so the row keeps the two people reach for and files the rest:
import { actionGroup, replicateAction, deleteAction } from "@zerotal/admin";
static recordActions() {
return [
viewAction(),
editAction(),
actionGroup([
replicateAction(),
action("archive").label("Archive").icon("folder").run(…),
deleteAction(),
]).label("More"),
];
}
Members are gated individually — a group whose every member is hidden draws nothing. Groups work in the header and the bulk toolbar too.
Replicate
replicateAction() copies a record and opens the copy for editing. The primary key
and timestamps are always dropped; name anything else that must stay unique:
replicateAction()
.excludeAttributes(["sku", "slug"])
.beforeReplicaSaved((data) => ({ ...data, name: `${data.name} (copy)`, status: "draft" })),
Import and export
import { exportAction, importAction, bulkExportAction } from "@zerotal/admin";
static headerActions() {
return [createAction(), exportAction(), importAction()];
}
static bulkActions() {
return [bulkExportAction(), bulkDeleteAction()];
}
Export writes the current list as CSV — the same search, filters, tab and sort
the user is looking at, not the whole table. Someone who has narrowed a view to the
twelve rows they care about expects twelve rows in the file. bulkExportAction()
exports the selection instead.
Pass "xlsx" for a spreadsheet instead:
return [createAction(), exportAction(), exportAction("xlsx")];
CSV stays the better interchange format and the default. The workbook is for the case
CSV genuinely cannot serve: a recipient who opens the file, finds 007 turned into
7 and a leading = treated as a formula, and reasonably calls the export broken.
Cells are written with real types, so dates sort as dates and numbers total, and the
header row is frozen with a filter over the used range.
Keep a column out of the file with .exportable(false). Pay, internal notes and
anything else that should not travel in a spreadsheet belongs behind that flag:
text("salary").exportable(false),
Import takes a CSV through a modal in two steps on one screen: pick a file, then confirm which field each of its columns feeds. The selects start on whatever the headers match — case, spaces, underscores and hyphens are all treated the same — so a file the panel exported needs no adjustment, and a file from somewhere else needs only the columns that didn't line up. Mapping a column to "skip" leaves it out.
Every row is validated through the resource's own fields, so an import cannot write anything a person could not have typed into the create form. A row that fails is reported by line number and skipped; one bad line out of five hundred does not discard the other four hundred and ninety-nine.
Large files
An import runs inline by default, capped at 2,000 rows — a synchronous import holds a WebSocket round-trip open, and a bigger file looks like a hang. Hand it to a queue instead and the cap lifts:
static headerActions() {
return [createAction(), exportAction(), importAction({ queue: true })];
}
The worker needs to be able to rebuild the job from its payload, so register the class where it can see it:
import { JobRegistry } from "@zerotal/queue";
import { ImportRecordsJob } from "@zerotal/admin";
JobRegistry.register(ImportRecordsJob);
With no queue configured, a queued import falls back to running inline rather
than silently doing nothing. @zerotal/queue stays an optional peer: it is
imported only when an import is actually queued.
Relations
Relation managers appear as tables on the View page.
import { hasMany, belongsToMany } from "@zerotal/admin";
static relations() {
return [
// Children referencing the parent — full CRUD links into their own resource.
hasMany(CommentResource, "post_id").title("Comments"),
// Many-to-many — attached rows with Detach + an Attach picker + pivot columns.
belongsToMany(TagResource, "tags")
.pivotColumns([{ key: "added_at", label: "Added" }]),
];
}
belongsToMany(Resource, "tags") drives the parent model's tags().attach() /
tags().detach() / tags().get().
Soft deletes
When the model uses the ORM SoftDeletes mixin, the List page gains an
Active / All / Trashed switch and the row + bulk actions gain Restore and
Force-delete automatically — no extra configuration.
Types
| Type | What it is |
|---|---|
ActionItem | One action as the panel holds it, after action() has built it. |
ActionHandler | The callback an action runs, given its ActionContext. |
ActionContext | What that callback receives — the record or selection, and the page. |
ActionVisible | The predicate deciding whether an action appears for this record at all. |
ActionColor | The emphasis it is drawn with. |
ConsoleAction, ConsoleHeaderAction | The console's own row and header actions. |
ActionVisible hides, it does not authorize. An action absent from the UI is still
reachable by anyone who can form the request, so the authorization check belongs in the handler
as well — see Authorization.
Next steps
- Admin overview — the guide's front page and the rest of the sections.
- Reference — the full API surface in one table.