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
@@ -23,7 +23,9 @@ function DashboardRoute() {
const { edit } = Route.useSearch()
// Widgets read live values; the canvas shell has no socket of its own.
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
// The working copy: an edit is the editor's until it is published, so what
// a wall panel is showing right now stays untouched by this session.
const { data: dashboard } = useQuery(dashboardQueryOptions(name, true))
if (!dashboard) return null
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, Plus } from "lucide-react"
import { LayoutDashboard } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import {
dashboardKeys,
dashboardsQueryOptions,
@@ -16,7 +20,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,23 @@ function Dashboards() {
onError: handleError.bind(showErrorToast),
})
// A summary carries no version, and publishing needs the one it is based on —
// so each dashboard's working copy is read right before it is published.
const publishAll = usePublishAll(
async (dashboard) => {
const current = await DashboardsService.readDashboard({
name: dashboard,
draft: true,
})
return DashboardsService.publishDashboard({
name: dashboard,
requestBody: { version: current.version ?? 1 },
})
},
"dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
)
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
@@ -59,6 +79,9 @@ function Dashboards() {
const dashboards = (data?.data ?? []).filter((dashboard) =>
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((dashboard) => dashboard.has_draft)
.map((dashboard) => dashboard.name)
return (
<div className="grid gap-6">
@@ -69,57 +92,52 @@ function Dashboards() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search dashboards"
aria-label="Search dashboards"
className="max-w-xs"
data-testid="search-dashboards"
onChange={(event) => setSearch(event.target.value)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search dashboards"
searchTestId="search-dashboards"
createLabel="New dashboard"
createTestId="new-dashboard"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-dashboard">
<Plus />
Create
</Button>
</DialogTrigger>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
<DialogDescription>
Name it after the panel it will hang on, or what it shows.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="kitchen"
aria-label="New dashboard name"
autoComplete="off"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
Create dashboard
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
<DialogDescription>
Name it after the panel it will hang on, or what it shows.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="kitchen"
aria-label="New dashboard name"
autoComplete="off"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
Create dashboard
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
@@ -140,6 +158,11 @@ function Dashboards() {
<span className="flex items-center gap-2">
<LayoutDashboard className="size-4 text-muted-foreground" />
{dashboard.title || dashboard.name}
{dashboard.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{dashboard.widget_count} widget
+67 -52
View File
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { Plus, Workflow } from "lucide-react"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import {
@@ -13,7 +17,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,20 @@ function Flows() {
onError: handleError.bind(showErrorToast),
})
// A summary carries no version, and publishing needs the one it is based
// on — so each flow's current version is read right before it is published.
const publishAll = usePublishAll(
async (flow) => {
const detail = await FlowsService.readFlow({ name: flow })
return FlowsService.publishFlow({
name: flow,
requestBody: { version: detail.definition.version ?? 1 },
})
},
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
const slug = name
.trim()
.toLowerCase()
@@ -58,6 +75,9 @@ function Flows() {
const flows = (data?.data ?? []).filter((flow) =>
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((flow) => flow.has_draft)
.map((flow) => flow.name)
return (
<div className="grid gap-6">
@@ -69,57 +89,52 @@ function Flows() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search flows"
aria-label="Search flows"
className="max-w-xs"
data-testid="search-flows"
onChange={(event) => setSearch(event.target.value)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search flows"
searchTestId="search-flows"
createLabel="New flow"
createTestId="new-flow"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-flow">
<Plus />
Create
</Button>
</DialogTrigger>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="heating"
aria-label="New flow name"
autoComplete="off"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
Create flow
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="heating"
aria-label="New flow name"
autoComplete="off"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
Create flow
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{flows.length === 0 ? (
<p className="text-sm text-muted-foreground">