Actions

Layer: Backend · custom (non-CRUD) operations

An action is a custom, non-CRUD endpoint on a resource — complete, a state-machine transition, a batch over selected rows. You write one Python function; codegen introspects its type annotations to build the route, request and response schemas, permission gate, and per-row visibility.

How it works

Codegen introspects the handler signature — a resource-model parameter means an object action at POST /{pk}/{slug}, its absence a collection action at POST /{slug} — and emits the schemas plus an x-action OpenAPI extension whose bulk flag the FE action-catalog reads to bucket the action. The can guard gates execution (403) and drives visibility: available_actions omits the action from a row’s serialized actions when it returns false, so the FE drops the button with no predicate.

The config you write

You writebe/jsonnet/resources/presets.libsonnet (the action() shorthand):

action(name, fn, status_code=null, can=null, bulk=false):: {
  type: "action",   // dispatch to the Action op regardless of name
  name: name,       // URL slug + x-action name
  fn: fn,           // dotted path to the async handler; its signature drives everything
  // status_code overrides the default: 204 for `-> None`, else 200.
  [if status_code != null then "status_code"]: status_code,
  // can: dotted (resource, session) -> bool; gates 403 + per-row visibility.
  [if can != null then "can"]: can,
  // bulk: stamp x-action.bulk; FE surfaces it on the multi-selection bar.
  [if bulk then "bulk"]: bulk,
},

An object action with a visibility guard — You writebe/config/resources/task.jsonnet:

local resource = import "be/resources/presets.libsonnet";

operations: [
  { name: "get", representation: "resource" },
  // ...create / update / delete...
  resource.action(
    name="complete",
    fn="tracker.actions.complete_task",
    // Server-side visibility gate. available_actions drops "complete"
    // from a row's `actions` list when this returns False -- the FE's
    // resolveObjectActions then filters the button out, no `!row.completed`
    // predicate on the FE side. Also gates execution: 403 if called anyway.
    can="tracker.guards.can_complete_task",
  ),
  // pre/post hooks are REJECTED on actions -- the handler's own `fn` is the
  // user entry point, so do preprocessing inline (hooks are create/update only).
]

The handler’s annotations are the schema. You writebe/tracker/actions.py + be/tracker/guards.py:

class CompleteRequest(BaseModel):
    note: str = ""

class CompleteResponse(BaseModel):
    id: str
    status: str
    note: str

async def complete_task(
    task: Task,                 # model param -> OBJECT action at POST /{id}/complete
    db: AsyncSession,           # request-scoped session, committed after the handler
    body: CompleteRequest,      # -> request schema (omit for a body-less action)
) -> CompleteResponse:          # -> response model (200). `-> None` emits 204; must be BaseModel|None
    task.completed = True
    db.add(task)
    # enqueue the downstream job via get_queue(db) in the SAME transaction
    # (transactional outbox) -- commit makes both durable together.
    ...
    return CompleteResponse(id=str(task.id), status="completed", note=body.note)

async def can_complete_task(task: Task, _session: Session) -> bool:
    # True iff not yet completed -> a completed task's row dump omits
    # "complete". Autogen success tests are SKIPPED when `can` is set (the
    # scaffold can't build a passing row) -- write the happy-path test yourself.
    return not task.completed

Codegen collects these into _generated/tracker/actions/task.py as ActionSpec(name="complete", can=can_complete_task, is_object_action=True); the serializers call available_actions(...) per row against that can. Row reps carry the actions envelope by default — opt out with exclude_actions: true on sync/cross-resource reps (see fields).

A bulk action needs a collection-scope handler (no model param) taking the selected ids in its body. You writebe/config/inventory_resources/asset.jsonnet + be/inventory/actions.py:

{ name: "retire", type: "action", fn: "inventory.actions.retire_assets",
  bulk: true, status_code: 204 },   // bulk on an object action is contradictory
class RetireAssetsRequest(BaseModel):
    ids: list[uuid.UUID]

async def retire_assets(
    *,
    body: RetireAssetsRequest,   # no model param -> COLLECTION action at POST /assets/retire
    db: AsyncSession,
    session: Session,
) -> None:                       # -> 204
    if not body.ids:
        return
    await db.execute(
        update(Asset).where(Asset.id.in_(body.ids)).values(status=AssetStatus.RETIRED),
    )

The frontend codegen emits

A typed catalog per resource — action names per scope plus the collection-permissions hook, regenerated every run. Generatedfe/src/_generated/actions/inventory/asset.gen.ts (do not edit):

export const assetActionCatalog = defineActionCatalog<ListItemAssetResource>()({
  object: ["view", "update", "delete", "send_email", "download_asset_pdf"],
  collection: ["view", "create"],
  bulk: ["retire"],                 // the bulk bucket, straight from x-action.bulk
  useCollectionPermissions: () =>   // bound suspense hook for header/bulk gating
    useGetAssetCollectionPermissionsSuspense().data,
});

You own the definitions module, scaffolded once with every action null (opted out) and typed against the catalog — an action the backend adds later won’t compile until you give it a definition or leave it null. Each entry carries exactly ONE of view (an overlay <FSHActionHost> renders with a ctx), to (a link), or onAction (inline, no form). Scaffolded, safe to editfe/src/actions/inventory/asset.tsx:

import {
  Dialog, Drawer, DrawerBody, FormErrors, FSHForm, SelectInput, Stack,
  TextFieldInput, toast, useFSHForm, defineResourceActions,
  type BulkActionContext, type ObjectActionContext,
} from "@fsh/components-library";

import { assetActionCatalog } from "../../_generated/actions/inventory/asset.gen";
import { downloadAssetPdf } from "../../_generated/actions/inventory/asset_download.gen";
import { AssetSendEmail } from "../../_generated/actions/inventory/asset_send_email.gen";
import { useGetAssetSuspense } from "../../_generated/queries/inventory/asset.gen";
import { assetRetireAction, deleteAsset, updateAsset } from "../../_generated/sdk/inventory/asset.gen";
import { assetStatus } from "../../enums/assetStatus";
import { urls } from "../../routes";

// Object overlay: ctx is ObjectActionContext (ctx.id / ctx.close / ctx.navigate).
// Build the form with useFSHForm + FSHForm, call the generated SDK fn, toast + close.
function AssetEditDrawer({ ctx }: { ctx: ObjectActionContext }) {
  const { data: asset } = useGetAssetSuspense({ path: { id: ctx.id } });
  const form = useFSHForm<{ name: string; status: AssetStatus }>({
    defaultValues: { name: asset.name, status: asset.status },
    submit: (values) =>
      updateAsset({ path: { id: ctx.id }, body: values }).then(() => {
        toast("Asset updated.", { variant: "success" });
        ctx.close();
      }),
  });
  return (
    <Drawer open onClose={ctx.close} title="Edit asset" footer={/* Cancel + Save */}>
      <DrawerBody>
        <FSHForm form={form}>
          <Stack gap="md">
            <FormErrors />
            <TextFieldInput name="name" label="Name" />
            <SelectInput name="status" label="Status" {...assetStatus.useSelectProps()} />
          </Stack>
        </FSHForm>
      </DrawerBody>
    </Drawer>
  );
}

// Bulk overlay: reads ctx.ids (BulkActionContext) and posts them to the collection SDK fn.
function AssetRetireConfirm({ ctx }: { ctx: BulkActionContext }) {
  const count = ctx.ids.length;
  const onConfirm = (): Promise<void> =>
    assetRetireAction({ body: { ids: [...ctx.ids] } }).then(() => {
      toast(`Retired ${count} assets.`, { variant: "success" });
      ctx.close();
    });
  return <Dialog open role="alertdialog" onClose={ctx.close} title="Retire assets" /* footer -> onConfirm */ />;
}

export const assetActions = defineResourceActions(assetActionCatalog, {
  resource: "asset",                        // the ?resource= key the overlay host addresses
  object: {
    view:   { label: "View", icon: "view", priority: 30, rowLink: true,       // the whole-row click
              to: (a) => urls.assets.detail.href({ id: a.id }) },
    update: { label: "Edit", icon: "edit", priority: 20,
              view: ({ ctx }) => <AssetEditDrawer ctx={ctx} /> },
    send_email: { label: "Send email", icon: "send", iconOnly: true, priority: 25,
              variant: "subtle", view: ({ ctx }) => <AssetSendEmail ctx={ctx} /> },
    download_asset_pdf: { label: "Download PDF", icon: "download", iconOnly: true,
              priority: 15, variant: "subtle", onAction: (a) => downloadAssetPdf(a.id) },  // inline, no form
    delete: { label: "Delete", icon: "delete", priority: 10,
              destructive: true, menuOnly: true,                               // danger tone, kebab-only
              view: ({ ctx }) => <AssetDeleteConfirm ctx={ctx} /> },
  },
  collection: {
    view:   { label: "Assets", icon: "columns", to: urls.assets.list().to, page: true },  // list page (palette)
    create: { label: "New asset", icon: "add", priority: 100, to: urls.assets.create().to },
  },
  bulk: {
    retire: { label: "Retire", icon: "archive", priority: 10, destructive: true,
              menuOnly: true, view: ({ ctx }) => <AssetRetireConfirm ctx={ctx} /> },
  },
});

The object surface gates each row against its own serialized actions (resolveObjectActions keeps only definitions the row still lists); collection and bulk gate off useCollectionPermissions. When can_complete_task returns false the backend drops complete from the row and the button vanishes — no FE predicate. Invalidate the relevant queries after the SDK call so actions refresh; how the catalog feeds the row menu, page header, and selection bar is table configuration. See permissions for how can composes with OPA and fields for the actions envelope and exclude_actions.