Skip to content

Actions

@action marks a model method as a UI button. One decorator, two transports — decided, as everywhere in situ, by the method's own effect shape: a sync body compiles to a client handler; an async body becomes a server command.

from situ.declui import action

Client actions (sync, on forms)

@dataclass
class CounterForm:
    count: Annotated[int, Field()] = 0

    @action
    def increment(self):
        self.count += 1

    @action
    def clear(self):
        self.count = 0

The method body is compiled: self.count becomes the generated count signal, and the result goes through the same dialect as a hand-written situ handler — the form's fields are the Locals. Several rules guard the translation, each failing closed:

  • an async action on a form (no server seam there),
  • a self.x that isn't a rendered field,
  • a local variable that would shadow a field name (it would silently write the signal),
  • a non-(self) signature, a reserved name (reset, value, editing),
  • visible=/message= on a form action (those are tracker vocabulary),

…are each a CompileError naming the fix.

Server actions (async, on the server tracker)

@dataclass
class Issue:
    id: Annotated[int, Field(hidden=True)]
    subject: Annotated[str, Field(label_field=True, search=True)]
    status: Status

    @action(visible="status != 'Closed'", message="Issue closed")
    async def close(self): ...

    @action(visible="status == 'Closed'", message="Issue reopened")
    async def reopen(self): ...

Each async action becomes a per-row button that POSTs /cmd/<name>/<id>; the generated handler awaits the corresponding facade verb (your facade wraps the DI-resolved service), and the region re-renders from fresh server state — the standard command round trip.

Gating: visible=

A predicate over the model's fields, compiled to a server-side Jinja gate around the button ({% if t.status.value == 'Open' %}…— enum fields compare by .value, matching the rendered cell). The render is a hint; the command dispatcher independently rejects anything not classified as a server handler, so a hidden action's endpoint isn't an open door.

Feedback: message=

A confirmation line ("Issue closed") shown the moment the button is clicked, in a <header> status line that survives the region swap and auto-hides after a few seconds. It rides the optimistic-apply pairing, so a failed command rolls the message back along with everything else. The message text is validated at generation time (no quotes/braces/control characters — it splices into the compiled click).

Bulk actions: bulk=True

@action(bulk=True, message="Selected issues closed")
async def bulk_close(self): ...

A bulk action makes the tracker grow a per-row checkbox column and a bulk bar; the handler receives the selected id set (a Local[set] riding the command as a JSON array — your facade verb takes the selection). Selection state lives client-side and the count updates with zero network.

What the capstone exercises

The issue_tracker example uses all of it from one model: three status-gated actions with messages, a bulk close, search=True filtering, and the relationship-walking detail pane — see Demos & examples.