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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user