Skip to content

6. The create dialog

Goal: a New-issue dialog — a form that POSTs a command, clears itself, and closes. This part completes the app.

The final component

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

from situ import Local, Server, Url

if TYPE_CHECKING:
    from typing import Protocol

    class IssuesFacade(Protocol):
        """The server-sited operations the handlers call (impl: IssueStore)."""

        async def create(self, title: str) -> None: ...
        async def set_status(self, issue_id: int, status: str) -> None: ...


reset: Callable[..., None]  # injected at exec: clears local signals to their defaults

search: Local[str] = ""          # the live-search box — client-only
selected_id: Local[int] = 0      # which row's detail is open (0 = none)
dialog_open: Local[bool] = False
new_title: Local[str] = ""
filter: Url[str] = "all"         # all | open | closed — a shareable link
issues: Server[IssuesFacade]     # the store: a facade in handlers, a row list in the template


async def create(new_title):
    await issues.create(new_title)
    reset("new_title", "dialog_open")  # clear the input, close the dialog


async def close(id):
    await issues.set_status(id, "closed")


async def reopen(id):
    await issues.set_status(id, "open")
<section class="tracker">
  <header>
    <input :bind="search" placeholder="Search title…" autocomplete="off">
    <button class="primary" @click="dialog_open = True">New issue</button>

    <div class="dialog" :show="dialog_open">
      <form @submit="create">
        <input :bind="new_title" name="new_title" placeholder="Title" autocomplete="off">
        <button class="primary">Create</button>
        <button type="button" @click="dialog_open = False">Cancel</button>
      </form>
    </div>
  </header>

  <div data-region>
    <nav>
      <a href="?filter=all" class="{{ 'selected' if filter == 'all' else '' }}">all</a>
      <a href="?filter=open" class="{{ 'selected' if filter == 'open' else '' }}">open</a>
      <a href="?filter=closed" class="{{ 'selected' if filter == 'closed' else '' }}">closed</a>
      <span class="count">{{ issues | length }} shown</span>
    </nav>

    <ul class="issue-list">
      {% for t in issues %}
      <li data-id="{{ t.id }}" data-title="{{ t.title }}"
          :show="search.lower() in t.title.lower()"
          :class="selected: selected_id == t.id">
        <button @click="selected_id = t.id">{{ t.title }}</button>
        <span class="status {{ t.status }}">{{ t.status }}</span>
      </li>
      {% endfor %}
    </ul>

    <div class="detail">
      {% for t in issues %}
      <div data-id="{{ t.id }}" :show="selected_id == t.id">
        <h2>{{ t.title }}</h2>
        <p>#{{ t.id }} · {{ t.status }}</p>
        {% if t.status == 'open' %}
        <button @click="close(t.id)">Close</button>
        {% else %}
        <button @click="reopen(t.id)">Reopen</button>
        {% endif %}
      </div>
      {% endfor %}
    </div>
  </div>
</section>

The new pieces, and why each is where it is:

  • The dialog lives in <header>. A command's region swap would tear down and rebuild anything inside the region — an open dialog included. The header survives swaps, so overlays, search boxes, and toasts belong there. (This is the two-roots rule from the Quickstart, now earning its keep.)
  • @submit="create" on the <form>, with the input carrying name="new_title" — a submitted form sends its fields, and the handler's new_title parameter binds from them. Note type="button" on Cancel: buttons inside a form default to type=submit, and an untyped Cancel would submit the form.
  • reset("new_title", "dialog_open") in the handler — the command's response carries a signal patch (the X-Siting-Signals header) that resets both signals to their defaults. The input clears and the dialog closes, with zero client code for either.

Restart: New issue → type a title → Create. One POST to /cmd/create, the list grows, the input is empty, the dialog is gone.

The finished tracker

What you built, by the numbers

Signal Site What it cost you
search Local one declaration + two binders; filtering is compiled JS, zero network
selected_id Local one declaration; selection survives command swaps for free
dialog_open, new_title Local a dialog with self-clearing state
filter Url three <a> tags; shareable, reload-surviving
issues Server a store, a facade Protocol, a context() — and three handlers that became three POST routes

Hand-written JavaScript: zero. The generated island for the finished app is ~6.8 KB; every line of it is readable at /island.js.

Where next

  • Split it into components. The header, the list, and the detail pane want to be separate files as this grows — Composition does that at compile time, and the island stays byte-identical.
  • Make it multi-client. mount_component(..., live=True) broadcasts mutations to every connected browser — see the wire protocol.
  • Style it with the kit. The component kit replaces the tutorial CSS with a design system, and <Dialog>/<Combobox>/<DataGrid> replace hand-rolled widgets.
  • Generate it. declui produces this app's whole shape — list, detail, gated actions — from one typed model; the capstone example is this tracker, grown up.
  • Read the flagship. demos/issues is this same app decomposed into a five-component tree with multi-select and bulk commands — run the demos.