Overviews: icon toolbar, dashboard drafts, publish all

Both overviews carried the same toolbar twice, left-aligned, with a search
field permanently taking a row of width. One `OverviewToolbar` now serves
them: the search folds into an icon and expands again on click (Escape puts
it away and hands focus back), create is a `+`, and everything sits right of
the page. Each page keeps its own create dialog — the toolbar only renders
the trigger — so the testids the runtime spec and the capture script drive
stayed where they were.

Dashboards get the flow store's draft/publish split. The editor autosaves
`dashboard.draft.json` beside `dashboard.json`; `/view/{name}`, `bindings_for`
and `history_requirements` keep reading the published file, so a wall panel
sees an edit only once someone publishes it. `POST /dashboards/{name}/publish`
and `/discard` mirror the flow routes down to the version precondition and the
409, `GET /dashboards/{name}?draft=true` is what the editor asks for, and the
dock grows the same Publish button — which flushes a queued save first, so an
autosave in flight is not published around. Creating a dashboard still writes
the published file directly: an empty document on a panel is harmless, and it
keeps the store free of a never-published case.

"Publish all" is a checkmark in the toolbar, live only when something actually
has `has_draft`. A summary carries no version and publish needs the one it is
based on, so each document's detail is read immediately before its publish —
honest against a stale list, and no version-less backend path to maintain.
Failures are counted rather than swallowed: three of five fails says so and
names the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:57:11 +02:00
co-authored by Claude Opus 5
parent 683c25b26d
commit 9351a86eec
14 changed files with 713 additions and 144 deletions
+58 -6
View File
@@ -26,6 +26,10 @@ class RenameRequest(BaseModel):
name: str
class PublishRequest(BaseModel):
version: int
def _apply_history_limits(store: Any, controller: Any) -> None:
"""Tell the engine how much past each charted message needs kept."""
controller.set_history_limits(store.history_requirements())
@@ -39,9 +43,12 @@ async def read_dashboards(store: DashboardStoreDep) -> Any:
@router.get("/{name}", response_model=DashboardDef)
async def read_dashboard(name: str, store: DashboardStoreDep) -> Any:
async def read_dashboard(
name: str, store: DashboardStoreDep, draft: bool = False
) -> Any:
"""What a panel shows, or with ``draft`` the copy the editor is on."""
try:
return await run_in_threadpool(store.read, name)
return await run_in_threadpool(store.read, name, draft)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
@@ -67,13 +74,46 @@ async def save_dashboard(
name: str,
body: DashboardDef,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Replace a dashboard, refusing a save someone else has moved past."""
"""Save unpublished changes, refusing a save someone else has moved past.
This writes a draft: panels keep showing the published document until
someone publishes, so nothing here can change what a wall is displaying —
which is also why the engine's history limits are left alone.
"""
if body.name != name:
raise HTTPException(status_code=422, detail="The name in the body must match")
try:
saved = await run_in_threadpool(store.write, body, body.version)
saved = await run_in_threadpool(store.write_draft, body, body.version)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved this dashboard first",
"current_version": exc.current,
},
)
return saved
@router.post("/{name}/publish", response_model=DashboardDef)
async def publish_dashboard(
name: str,
body: PublishRequest,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Put the unpublished changes on the panels."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
try:
published = await run_in_threadpool(store.publish, name, body.version)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
@@ -83,7 +123,19 @@ async def save_dashboard(
},
)
await run_in_threadpool(_apply_history_limits, store, controller)
return saved
return published
@router.post("/{name}/discard", response_model=DashboardDef)
async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
"""Throw the unpublished changes away and go back to what is shown."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
return await run_in_threadpool(store.discard_draft, name)
@router.delete("/{name}", response_model=Message)
+91 -10
View File
@@ -6,9 +6,11 @@ reads across flows without being part of any of them, and a flow stays the
logic it was.
Stored beside the flows in the same git repository, under a directory the flow
listing ignores. No draft/publish split: nothing executes a dashboard, so edit
mode is its own staging area and the version counter is enough to stop two
clients overwriting each other.
listing ignores. Editing is separated from showing, exactly as it is for flows:
the editor writes ``dashboard.draft.json`` and a wall panel reads only the
published ``dashboard.json``, so a half-arranged page never reaches the wall.
Publishing promotes the draft and removes it; a dashboard directory without one
is simply a dashboard with nothing unpublished.
"""
from __future__ import annotations
@@ -189,6 +191,9 @@ class DashboardDef(BaseModel):
pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
#: Whether there are unpublished changes. Reported by the store on read,
#: never stored — the draft file's existence is the only record of it.
has_draft: bool = False
@field_validator("name")
@classmethod
@@ -207,6 +212,7 @@ class DashboardSummary(BaseModel):
title: str = ""
page_count: int = 0
widget_count: int = 0
has_draft: bool = False
class DashboardsPublic(BaseModel):
@@ -242,6 +248,14 @@ class DashboardStore:
def _file(self, name: str) -> Path:
return self.root / name / "dashboard.json"
def _draft_file(self, name: str) -> Path:
return self.root / name / "dashboard.draft.json"
@staticmethod
def _dump(defn: DashboardDef) -> str:
"""What goes on disk. ``has_draft`` is the file layout, not a field."""
return defn.model_dump_json(indent=2, exclude={"has_draft"})
def list(self) -> list[DashboardSummary]:
summaries = []
for path in sorted(self.root.glob("*/dashboard.json")):
@@ -255,6 +269,7 @@ class DashboardStore:
title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets),
has_draft=self.has_draft(defn.name),
)
)
return summaries
@@ -262,16 +277,29 @@ class DashboardStore:
def exists(self, name: str) -> bool:
return self._file(name).exists()
def read(self, name: str) -> DashboardDef:
path = self._file(name)
def has_draft(self, name: str) -> bool:
"""Are there unpublished changes to this dashboard?"""
return self._draft_file(name).exists()
def read(self, name: str, draft: bool = False) -> DashboardDef:
"""The published dashboard, or with ``draft`` the working copy."""
path = self._draft_file(name) if draft else self._file(name)
if not path.exists():
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
return DashboardDef.model_validate_json(path.read_text())
return DashboardDef.model_validate_json(path.read_text()).model_copy(
update={"has_draft": self.has_draft(name)}
)
def write(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Save, refusing a write based on a version someone has moved past."""
"""Publish a dashboard directly — what creating one does.
Every later edit goes through :meth:`write_draft`, so this only ever
writes the published file of a dashboard nobody has a draft of.
"""
with self._lock, self.flows._write_lock:
path = self._file(defn.name)
current = 0
@@ -280,18 +308,64 @@ class DashboardStore:
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1})
saved = defn.model_copy(update={"version": current + 1, "has_draft": False})
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(saved.model_dump_json(indent=2))
path.write_text(self._dump(saved))
self.flows._commit(f"Save dashboard '{defn.name}'")
return saved
def write_draft(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Save unpublished changes, refusing to overwrite someone else's.
``base_version`` is the version the editor last saw — of the working
copy, which is the draft once there is one.
"""
with self._lock, self.flows._write_lock:
if not self.exists(defn.name):
raise DashboardNotFound(defn.name)
current = self.read(defn.name, draft=True).version
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1, "has_draft": True})
path = self._draft_file(defn.name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Update draft of dashboard '{defn.name}'")
return saved
def publish(self, name: str, base_version: int | None = None) -> DashboardDef:
"""Promote the working copy to what the panels show."""
with self._lock, self.flows._write_lock:
current = self.read(name, draft=True)
if base_version is not None and base_version != current.version:
raise StaleVersion(name, current.version)
draft = self._draft_file(name)
if draft.exists():
self._file(name).write_text(self._dump(current))
draft.unlink()
self.flows._commit(f"Publish dashboard '{name}'")
return current.model_copy(update={"has_draft": False})
def discard_draft(self, name: str) -> DashboardDef:
"""Throw the unpublished changes away and go back to what is shown."""
with self._lock, self.flows._write_lock:
draft = self._draft_file(name)
if draft.exists():
draft.unlink()
self.flows._commit(f"Discard draft of dashboard '{name}'")
return self.read(name)
def delete(self, name: str) -> None:
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
with self.flows._write_lock:
path.unlink()
self._draft_file(name).unlink(missing_ok=True)
try:
path.parent.rmdir()
except OSError:
@@ -306,7 +380,14 @@ class DashboardStore:
renamed = defn.model_copy(update={"name": new_name})
target = self._file(new_name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(renamed.model_dump_json(indent=2))
target.write_text(self._dump(renamed))
# An unpublished edit belongs to the dashboard, so it moves too.
if self.has_draft(name):
draft = self.read(name, draft=True)
self._draft_file(new_name).write_text(
self._dump(draft.model_copy(update={"name": new_name}))
)
self._draft_file(name).unlink()
self._file(name).unlink()
try:
self._file(name).parent.rmdir()
+28
View File
@@ -59,6 +59,34 @@ def test_a_save_based_on_a_version_someone_moved_past_is_refused(
store.write(first, first.version)
def test_an_edit_reaches_a_panel_only_once_it_is_published(store: DashboardStore):
"""A wall panel reads the published file; the editor writes beside it."""
published = store.write(default_dashboard("house"))
draft = store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.has_draft("house")
assert store.read("house").title == published.title
assert store.read("house", draft=True).title == "Kitchen"
store.publish("house", draft.version)
assert store.read("house").title == "Kitchen"
assert not store.has_draft("house")
def test_discarding_leaves_what_is_published(store: DashboardStore):
published = store.write(default_dashboard("house"))
store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.discard_draft("house").title == published.title
assert not store.has_draft("house")
def test_deleting_and_renaming(store: DashboardStore):
store.write(default_dashboard("house"))