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.
Next steps
- Admin overview — the guide's front page and the rest of the sections.
- Reference — the full API surface in one table.