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 write — be/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 write —
be/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 write —
be/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 write —
be/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. Generated —
fe/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 edit —
fe/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.