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.
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
asyncaction on a form (no command route there), - a
self.xthat 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.
Navigating: Redirect¶
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¶
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.
Per-row links: RowLink¶
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.