Rework the dashboards onto the flow canvas
One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
|
||||
type Search = { edit?: boolean }
|
||||
|
||||
export const Route = createFileRoute("/_canvas/dashboards/$name")({
|
||||
component: DashboardRoute,
|
||||
// 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,
|
||||
}),
|
||||
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
|
||||
})
|
||||
|
||||
function DashboardRoute() {
|
||||
const { name } = Route.useParams()
|
||||
const { edit } = Route.useSearch()
|
||||
// Widgets read live values; the canvas shell has no socket of its own.
|
||||
useFlowSocket()
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
|
||||
if (!dashboard) return null
|
||||
|
||||
return (
|
||||
<DashboardEditor
|
||||
// Leaving edit mode drops the draft, so the session starts clean.
|
||||
key={`${name}:${edit ? "edit" : "view"}`}
|
||||
dashboard={dashboard as Dashboard}
|
||||
edit={Boolean(edit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { FlowsService } from "@/client"
|
||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export const Route = createFileRoute("/_canvas/flows/")({
|
||||
component: () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<FlowsIndex />
|
||||
</Suspense>
|
||||
),
|
||||
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
|
||||
})
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-32 w-64 rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowsIndex() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
FlowsService.saveFlow({
|
||||
name: "my_first_flow",
|
||||
requestBody: { name: "my_first_flow", nodes: [], inputs: [] },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: "my_first_flow" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// With flows around, open the first one rather than showing an empty page.
|
||||
if (flows.data.length > 0) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flows.data[0].name }}
|
||||
replace
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<span className="flex size-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Workflow className="size-7" />
|
||||
</span>
|
||||
<div className="grid gap-1">
|
||||
<h1 className="font-display text-lg font-medium">No flows yet</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
A flow is a handful of nodes passing messages to each other. Start
|
||||
with one and add nodes as you go.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending}
|
||||
data-testid="create-first-flow"
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create a flow"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function Dashboards() {
|
||||
const navigate = useNavigate()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const [name, setName] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (dashboard: string) =>
|
||||
@@ -38,12 +39,15 @@ function Dashboards() {
|
||||
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, "_")
|
||||
const needle = search.trim().toLowerCase()
|
||||
const dashboards = (data?.data ?? []).filter((dashboard) =>
|
||||
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -54,34 +58,46 @@ function Dashboards() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex max-w-md items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (slug) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="New dashboard"
|
||||
aria-label="New dashboard name"
|
||||
data-testid="new-dashboard-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
value={search}
|
||||
placeholder="Search dashboards"
|
||||
aria-label="Search dashboards"
|
||||
className="max-w-xs"
|
||||
data-testid="search-dashboards"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={!slug || create.isPending}
|
||||
data-testid="create-dashboard"
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (slug) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No dashboards yet. Name one above to start.
|
||||
{needle
|
||||
? "No dashboard matches that."
|
||||
: "No dashboards yet. Name one above to start."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||
import { Plus, Workflow } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { FlowsService } from "@/client"
|
||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/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/flows/")({
|
||||
component: Flows,
|
||||
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
|
||||
})
|
||||
|
||||
/** The shape the store accepts, so a bad name is caught before the request. */
|
||||
const NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
function Flows() {
|
||||
const { data } = useQuery(flowsQueryOptions())
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const [name, setName] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (flow: string) =>
|
||||
FlowsService.saveFlow({
|
||||
name: flow,
|
||||
requestBody: { name: flow, nodes: [], inputs: [] },
|
||||
}),
|
||||
onSuccess: (_saved, flow) => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
const needle = search.trim().toLowerCase()
|
||||
const flows = (data?.data ?? []).filter((flow) =>
|
||||
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
<h1 className="text-2xl">Flows</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A flow is a handful of nodes passing messages to each other. Keep them
|
||||
small and name them after what they do.
|
||||
</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)}
|
||||
/>
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (NAME.test(slug)) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="New flow"
|
||||
aria-label="New flow name"
|
||||
data-testid="new-flow-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={!NAME.test(slug) || create.isPending}
|
||||
data-testid="create-flow"
|
||||
>
|
||||
<Plus />
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{flows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{needle
|
||||
? "No flow matches that."
|
||||
: "No flows yet. Name one above to start."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<Link
|
||||
key={flow.name}
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flow.name }}
|
||||
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
||||
data-testid="flow-card"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Workflow className="size-4 text-muted-foreground" />
|
||||
{flow.title || flow.name}
|
||||
{flow.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">
|
||||
{flow.node_count ?? 0} node
|
||||
{flow.node_count === 1 ? "" : "s"} ·{" "}
|
||||
{flow.enabled === false ? "stopped" : "running"}
|
||||
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { DashboardView } from "@/components/Dashboard/DashboardView"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
|
||||
/**
|
||||
* What a wall panel is pointed at.
|
||||
*
|
||||
* Deliberately outside both shells: no sidebar, no footer, no editing, and no
|
||||
* column cap — the widgets are the whole page. Route splitting means a panel
|
||||
* never downloads the editor or the grid library either.
|
||||
*/
|
||||
export const Route = createFileRoute("/view/$name")({
|
||||
component: PanelView,
|
||||
beforeLoad: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
},
|
||||
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
|
||||
})
|
||||
|
||||
function PanelView() {
|
||||
const { name } = Route.useParams()
|
||||
useFlowSocket()
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
|
||||
return (
|
||||
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
|
||||
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user