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 command route 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 — an apostrophe is fine ("Sujet refusé, l'auteur a été notifié" compiles; it is escaped into the status-line string), a double quote, semicolon, backslash, brace, or control character fails closed.

Button text: label=

By default the button reads the method name, title-cased (close → "Close"). Pass label= for an explicit, accented, or localized label the method name can't carry:

@action(label="Dépublier", visible="status != 'draft'", message="Article dépublié")
async def depublier(self): ...

The label is display-only — the command still POSTs by the method name. label= works on client (form) actions too.

A command re-renders its own region by default. To send the user to another screen on success, return a Redirect from the facade verb:

from situ import Redirect

class Sujets:
    async def accept(self, sujet_id):
        commande = await self.svc.accept_as_commande(sujet_id)
        return Redirect(f"/commandes/{commande.id}")     # the browser navigates here

The mount turns it into a full page navigation (the region isn't re-rendered); returning None keeps the in-place swap. The URL is a host-constructed same-origin path (guarded against open redirects), the way row_links resolve their href.

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.

Not every per-row control mutates. A RowLink on the Screen renders a plain navigation <a> beside the command buttons — for "View" / "Edit" links that go to another page:

from situ.declui import RowLink

Screen(
    model=Doc,
    row_links=(
        RowLink("View", href="view_url"),
        RowLink("Edit", href="edit_url", visible="is_owner"),   # gated, like @action(visible=)
    ),
)

href names a row field carrying the resolved URL — declui can't know your routes, so context() fills e.g. view_url on each row and the link renders <a href="../{{ t.view_url }}"> (a dangerous scheme like javascript: is neutralized). visible= is the same server-row predicate @action uses, so a conditional link no longer needs a no-op command to get its gate. A tracker with only links (no @action) is valid — a links-only or read-only list.

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.