# Actions (/docs/admin/collections/actions)

---
title: Actions
description: 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.
kind: reference
package: "@questpie/admin"
---

## Where a button can go

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

| Builder                         | Scope    | Appears in                          | Extra on `ctx` |
| ------------------------------- | -------- | ----------------------------------- | -------------- |
| `a.headerAction({…})`           | `header` | The list header, beside Create      | nothing        |
| `a.bulkAction({…})`             | `bulk`   | The selection toolbar               | `itemIds`      |
| `a.action({…})`                 | `single` | The edit form's secondary menu      | `itemId`       |
| `a.action({ scope: "row", … })` | `row`    | The row menu, and the edit form too | `itemId`       |

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

```ts title="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.

<Callout type="warn" title="`.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.
</Callout>

## The definition

| Key            | Required | Holds                                                        |
| -------------- | -------- | ------------------------------------------------------------ |
| `id`           | Yes      | Unique within the collection. It is what the route receives. |
| `label`        | Yes      | Button text                                                  |
| `handler`      | Yes      | The function, run on the server                              |
| `icon`         | No       | A `c.icon(…)` reference                                      |
| `variant`      | No       | `default`, `destructive`, `outline`, `secondary` or `ghost`  |
| `form`         | No       | Ask for input before running                                 |
| `confirmation` | No       | Ask "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

```ts
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`.

<Callout type="warn" title="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.
</Callout>

## Confirming first

```ts
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 `ctx`                                      | Is                                               |
| --------------------------------------------- | ------------------------------------------------ |
| `data`                                        | The submitted form, or `{}`                      |
| `itemId`                                      | The row, for a single or row action              |
| `itemIds`                                     | The selection, for a bulk action                 |
| `collections`, `globals`                      | The same typed CRUD you use in a route           |
| `db`, `session`, `locale`                     | The connection, the caller, their content locale |
| `auth`                                        | The Better Auth API                              |
| `t`                                           | Translates an admin message key                  |
| `queue`, `email`, `storage`, `kv`, `services` | The rest of the app surface                      |

The handler returns one of three results.

```ts
{ 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.

| Effect                  | Does                        |
| ----------------------- | --------------------------- |
| `invalidate: true`      | Refetches everything        |
| `invalidate: ["posts"]` | Refetches those collections |
| `redirect: "/admin/…"`  | Navigates after the toast   |
| `closeModal: true`      | Closes 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.

| Where             | Buttons                                      |
| ----------------- | -------------------------------------------- |
| List header       | Create                                       |
| List row menu     | nothing                                      |
| Selection toolbar | Delete selected, Restore selected, Duplicate |
| Edit form         | Duplicate, 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](/docs/admin/collections/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.

```ts
.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](/docs/schema/access-control).

## Where each topic lives

| Topic                               | Page                                               |
| ----------------------------------- | -------------------------------------------------- |
| The other four methods              | [Collections and globals](/docs/admin/collections) |
| Which built-in buttons a list shows | [The list](/docs/admin/collections/list)           |
| The form these buttons sit beside   | [The form](/docs/admin/collections/form)           |
| Who may sign in at all              | [Authentication](/docs/admin/auth)                 |
| Work that should not block a click  | [Jobs](/docs/code/jobs)                            |

## Next

**[Preview](/docs/admin/collections/preview)** puts the page a row renders as
next to the form that edits it.
