Skip to content

List actions

List actions are bulk verbs on selected rows. Every list has a built-in Delete selected action when the current admin has delete permission. It is the default action, honours scope, runs the model's delete hooks, and writes a delete audit event.

You can add custom actions for other operations, such as publishing posts:

ts
import { prismaActionWhere } from "@paneljs/prisma";

admin.register("Post", {
  actions: [
    {
      name: "publish_selected",
      label: "Publish selected posts",
      allowedRoles: ["SUPER_ADMIN", "ADMIN"],
      handler: async ({ client, where }) => {
        const result = await client.post.updateMany({
          where: prismaActionWhere("id", where),
          data: { published: true },
        });
        return { message: `Published ${result.count} posts.` };
      },
    },
  ],
});
ts
import { typeormActionWhere } from "@paneljs/typeorm";
import type { DataSource } from "typeorm";

admin.register("Post", {
  actions: [
    {
      name: "publish_selected",
      label: "Publish selected posts",
      allowedRoles: ["SUPER_ADMIN", "ADMIN"],
      handler: async ({ client, where }) => {
        const result = await (client as DataSource)
          .getRepository("Post")
          .update(typeormActionWhere("id", where), { published: true });
        const count = result.affected ?? 0;
        return { message: `Published ${count} posts.` };
      },
    },
  ],
});
ts
import { mikroormActionWhere } from "@paneljs/mikroorm";
import type { MikroORM } from "@mikro-orm/core";

admin.register("Post", {
  actions: [
    {
      name: "publish_selected",
      label: "Publish selected posts",
      allowedRoles: ["SUPER_ADMIN", "ADMIN"],
      handler: async ({ client, where }) => {
        const orm = client as MikroORM;
        const count = await orm.em
          .fork()
          .nativeUpdate("Post", mikroormActionWhere(orm, "Post", where), {
            published: true,
          });
        return { message: `Published ${count} posts.` };
      },
    },
  ],
});

The UI shows label. The route is POST /admin/api/posts/actions/publish_selected.

What the handler receives

ts
{
  ids: Array<string | number>; // only rows that passed scope
  adminUser: AdminUser;
  client: unknown; // Prisma client, TypeORM DataSource, or MikroORM instance
  where: {
    scope: Record<string, unknown>;
    ids: Array<string | number>;
  }
}

Return { message: string }. That string is what the UI toasts.

Safety checks, in order

  1. Caller is authenticated
  2. Caller has list permission on the model
  3. The action exists and the caller may run it (allowedRoles, or permissions.actions[name])
  4. Body is { ids: [...] } — 1 to 100 unique string/number ids
  5. Those rows are reloaded with the action where (scope + selected ids)
  6. If any id is missing, the action does not run (400)
  7. Handler, then optional audit { type: "action", metadata: { action } }

Ada cannot publish Grace’s draft by pasting its id into the request.

Permissions

allowedRoles on the action is the usual allowlist. Super-admin bypasses it. An action must define allowedRoles, permissions.actions[name], or both; omitting both denies the action.

You can also set permissions.actions.publish_selected. Both are enforced when present.

The schema endpoint only lists actions this person may run. Hidden in the UI is not the boundary — the POST is.

The built-in delete action uses the model's delete permission; it cannot be changed through permissions.actions.

Do the work safely

Use where.scope and ids (or where.ids) in every mutation so the action stays tenant-safe. where is an adapter-neutral target, not an ORM-native query object.

  • Prisma: prismaActionWhere("id", where) from @paneljs/prisma
  • TypeORM: typeormActionWhere("id", where) from @paneljs/typeorm
  • MikroORM: mikroormActionWhere(orm, "Post", where) from @paneljs/mikroorm

Released under the MIT License.