QUESTPIE
AdminCollections

Actions

An action is a button in the panel with a handler on the server. It can ask for input first, confirm before it runs, and then invalidate, redirect or just say it worked.

View markdown

Where a button can go

.actions() declares them. Which builder you call decides where the button lands, and what the handler is given.

BuilderScopeAppears inExtra on ctx
a.headerAction({…})headerThe list header, beside Createnothing
a.bulkAction({…})bulkThe selection toolbaritemIds
a.action({…})singleThe edit form's secondary menuitemId
a.action({ scope: "row", … })rowThe row menu, and the edit form tooitemId

An action on the edit form shows while editing a row. It is never on the create screen, because there is no row yet.

One action

src/questpie/server/collections/posts.ts
.actions(({ a, c, f }) => ({
	custom: [
		a.action({
			id: "publish",
			label: { en: "Publish", sk: "Publikovať" },
			icon: c.icon("ph:paper-plane-tilt"),
			form: {
				title: { en: "Publish this post" },
				fields: {
					note: f.textarea().label({ en: "Release note" }),
				},
			},
			confirmation: {
				title: { en: "Publish now?" },
				description: { en: "It goes live for everyone." },
			},
			handler: async ({ itemId, data, collections, queue }) => {
				await collections.posts.updateById({
					id: itemId!,
					data: { status: "published" },
				});
				await queue.notifySubscribers.publish({ note: data.note });
				return {
					type: "success",
					toast: { message: "Published" },
					effects: { invalidate: ["posts"] },
				};
			},
		}),
	],
}))

The Publish button now sits in the post's edit form. It asks for confirmation, then for a release note, then runs the handler on the server.

`.actions()` on its own does nothing

Introspection only emits an admin block when the collection also calls .admin(), .list(), .form() or .preview(). A collection with nothing but .actions() sends no custom actions to the panel.

The definition

KeyRequiredHolds
idYesUnique within the collection. It is what the route receives.
labelYesButton text
handlerYesThe function, run on the server
iconNoA c.icon(…) reference
variantNodefault, destructive, outline, secondary or ghost
formNoAsk for input before running
confirmationNoAsk "are you sure" before running

Give both a form and a confirmation and the confirmation comes first. The reader agrees, then fills the form in. Pick one.

Asking for input

form: {
	title: { en: "Send email" },
	description: { en: "Goes to every selected reader." },
	fields: {
		subject: f.text().label({ en: "Subject" }).required(),
		body: f.textarea().label({ en: "Body" }),
		priority: f.select([
			{ value: "low", label: { en: "Low" } },
			{ value: "high", label: { en: "High" } },
		]),
	},
	submitLabel: { en: "Send" },
	width: "lg",
}

fields is a record, not an array, and the keys are what land on data in the handler. The f here is not the field-name proxy from .list(). It builds a field, chainably, the way .fields() does. width is sm, md, lg or xl.

Validate inside the handler

The panel builds a schema from these fields and blocks a bad submit. The server's own check does not currently reject anything. Treat data as untrusted and check it yourself.

Confirming first

confirmation: {
	title: { en: "Delete every draft?" },
	description: { en: "This cannot be undone." },
	confirmLabel: { en: "Delete" },
	destructive: true,
}

Only title is required. destructive: true paints the confirm button red.

The handler

It runs on the server, inside the caller's session, in user access mode. So your .access() rules apply to anything it reads or writes.

On ctxIs
dataThe submitted form, or {}
itemIdThe row, for a single or row action
itemIdsThe selection, for a bulk action
collections, globalsThe same typed CRUD you use in a route
db, session, localeThe connection, the caller, their content locale
authThe Better Auth API
tTranslates an admin message key
queue, email, storage, kv, servicesThe rest of the app surface

The handler returns one of three results.

{ type: "success", toast: { message: "Done" }, effects: {} }
{ type: "error", toast: { message: "Nope" }, errors: { subject: "Too long" } }
{ type: "redirect", url: "/preview/posts/1", external: true }

errors on an error result maps a form field to a message. A failed action can point at the input that caused it. A thrown exception becomes an error toast carrying the message, and the panel keeps working.

Effects

effects rides along on a success result.

EffectDoes
invalidate: trueRefetches everything
invalidate: ["posts"]Refetches those collections
redirect: "/admin/…"Navigates after the toast
closeModal: trueCloses the action dialog

type: "redirect" and effects.redirect both navigate. The difference is that a redirect result takes external: true and opens a new tab.

The built-in buttons

You declare none of these. They come from the client's own defaults.

WhereButtons
List headerCreate
List row menunothing
Selection toolbarDelete selected, Restore selected, Duplicate
Edit formDuplicate, Restore, Delete

Restore and Restore selected only show themselves on a row that has a deletedAt. Duplicate in the toolbar only shows when exactly one row is selected.

To change which ones show, use .list({ actions }). It replaces the default for each section you name. Declaring any custom action also clears the header default, so name Create again if you still want it. See The list.

The builtin key on .actions() is a different thing. It is the allowlist the executeAction route checks before it runs a built-in operation by name. It does not add or remove a button anywhere.

.actions(({ a }) => ({
	builtin: [a.create(), a.save(), a.delete()],
}))

Leave it unset and everything is allowed. That is create, save, delete, deleteMany, restore, restoreMany, duplicate, and the workflow transition. Naming a shorter list turns the rest into "action not found".

Access

Every action goes through one route, and that route requires session.user.role === "admin". The handler then runs under the caller's own session in user mode. A collection they cannot write still refuses them.

Admin config is not a permission boundary. Hiding a button hides a button. See Access control.

Where each topic lives

TopicPage
The other four methodsCollections and globals
Which built-in buttons a list showsThe list
The form these buttons sit besideThe form
Who may sign in at allAuthentication
Work that should not block a clickJobs

Next

Preview puts the page a row renders as next to the form that edits it.

On this page