Build dashboards you can actually look at and press

Widgets bind to a message name and read it live off the socket the editor
already had — lifted out of the flow editor so a dashboard route gets the
same values, which also gives the home page live data for free.

The input widgets close the loop the other way: a slider publishes into
the graph and whatever consumes that message runs. Verified end to end in
the running app — moving a slider set a flow input, and the stat bound to
what the flow computed from it followed.

View mode is plain CSS grid. A wall panel that only displays should not
download the code that lets someone drag things around, and it now does
not. Editing is a widget picker, a per-widget width control and a
settings card fed by the message catalog.

No new dependencies: the slider is a range input, the gauge is an arc,
and the markdown is a five-line subset. Charts are the one widget still
missing — they need a charting library and the chart tokens the design
guidelines reserved — so they are stored and validated but not offered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
2026-08-16 09:33:02 +02:00
co-authored by Claude Fable 5
parent 895bd89b2b
commit be6d4fc13c
15 changed files with 1388 additions and 7 deletions
+5
View File
@@ -1,6 +1,7 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import { Footer } from "@/components/Common/Footer"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import AppSidebar from "@/components/Sidebar/AppSidebar"
import {
SidebarInset,
@@ -21,6 +22,10 @@ export const Route = createFileRoute("/_layout")({
})
function Layout() {
// Dashboards live in this shell and read live values, so the socket belongs
// here rather than only inside the editor.
useFlowSocket()
return (
<SidebarProvider className="bg-card">
<AppSidebar />
@@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Check, Pencil, Trash2 } from "lucide-react"
import { useEffect, useState } from "react"
import { type DashboardDef_Output, DashboardsService } from "@/client"
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
import { DashboardView, pagesOf } from "@/components/Dashboard/DashboardView"
import {
dashboardKeys,
dashboardQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { edit?: boolean }
export const Route = createFileRoute("/_layout/dashboards/$name")({
component: Dashboard,
// Edit mode is a search param so viewing stays the default a panel opens
// into, and an editing session is a link someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
edit: search.edit === true || search.edit === "true" || undefined,
}),
})
function Dashboard() {
const { name } = Route.useParams()
const { edit } = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const [pageId, setPageId] = useState<string | undefined>(undefined)
useEffect(() => {
if (dashboard && !pageId) setPageId(pagesOf(dashboard)[0]?.id)
}, [dashboard, pageId])
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({ to: "/dashboards" })
},
onError: handleError.bind(showErrorToast),
})
if (!dashboard) return null
const setEdit = (next: boolean) =>
navigate({
to: "/dashboards/$name",
params: { name },
search: next ? { edit: true } : {},
})
return (
<div className="grid gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl">{dashboard.title || dashboard.name}</h1>
<div className="flex items-center gap-2">
{edit ? (
<Button
variant="secondary"
onClick={() => remove.mutate()}
data-testid="delete-dashboard"
>
<Trash2 />
Delete
</Button>
) : null}
<Button
variant={edit ? "brand" : "secondary"}
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
{edit ? <Check /> : <Pencil />}
{edit ? "Done" : "Edit"}
</Button>
</div>
</div>
{pagesOf(dashboard).length > 1 ? (
<Tabs value={pageId} onValueChange={setPageId}>
<TabsList>
{pagesOf(dashboard).map((page) => (
<TabsTrigger key={page.id} value={page.id}>
{page.title || page.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{edit ? (
<DashboardEditor
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
) : (
<DashboardView
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
)}
</div>
)
}
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, Plus } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
dashboardKeys,
dashboardsQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/dashboards/")({
component: Dashboards,
})
function Dashboards() {
const { data } = useQuery(dashboardsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const create = useMutation({
mutationFn: (dashboard: string) =>
DashboardsService.createDashboard({ name: dashboard }),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({
to: "/dashboards/$name",
params: { name: created.name },
search: { edit: true },
})
},
onError: handleError.bind(showErrorToast),
})
const dashboards = data?.data ?? []
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Dashboards</h1>
<p className="text-sm text-muted-foreground">
What a wall panel shows, built from the messages your flows carry.
</p>
</div>
<form
className="flex max-w-md items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<Input
value={name}
placeholder="New dashboard"
aria-label="New dashboard name"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
<Plus />
Create
</Button>
</form>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards yet. Name one above to start.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{dashboards.map((dashboard) => (
<Link
key={dashboard.name}
to="/dashboards/$name"
params={{ name: dashboard.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="dashboard-card"
>
<span className="flex items-center gap-2">
<LayoutDashboard className="size-4 text-muted-foreground" />
{dashboard.title || dashboard.name}
</span>
<span className="text-sm text-muted-foreground">
{dashboard.widget_count} widget
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
{dashboard.page_count} page
{dashboard.page_count === 1 ? "" : "s"}
</span>
</Link>
))}
</div>
)}
</div>
)
}