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