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