Files
app/frontend/src/components/Dashboard/DashboardEditor.tsx
T
stroblmeandClaude Fable 5 0af09eedbe 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
2026-08-16 17:13:10 +02:00

528 lines
16 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Check,
ExternalLink,
Loader2,
Pencil,
Plus,
Settings2,
} from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
import "react-grid-layout/css/styles.css"
import {
type ApiError,
type DashboardDef_Output,
DashboardsService,
type Placement,
type WidgetDef,
} from "@/client"
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
columnsOf,
type Dashboard,
DashboardView,
GRID_GAP,
isPlaced,
pagesOf,
placement,
ROW_HEIGHT,
sectionsOf,
widgetsOf,
} from "./DashboardView"
import { DashboardPanel, WidgetPanel } from "./panels"
import { dashboardKeys, useSaveDashboard } from "./queries"
import {
WIDGET_LABELS,
WIDGET_SIZES,
WidgetBody,
WidgetFrame,
type WidgetKind,
widgetIssue,
} from "./widgets"
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
/** How long to sit on edits before saving, so typing is not a save per key. */
const AUTOSAVE_MS = 800
/**
* Controls a widget owns. A press on one of these is the widget's own — a
* slider still slides in edit mode — so it never counts as picking the widget.
*/
const INTERACTIVE =
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
pagesOf(dashboard).flatMap((page) =>
sectionsOf(page).flatMap((section) =>
widgetsOf(section).map((widget) => widget.id),
),
),
)
let candidate = type
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
return candidate
}
/**
* Positions for a dashboard nobody has arranged yet.
*
* Widths were the only thing the old editor set, so every widget sits at 0,0.
* Shelf-packing them into the grid is what the browser was doing implicitly;
* writing it down is what lets them be dragged from there.
*/
function packed(widgets: WidgetDef[], columns: number): Layout {
let x = 0
let y = 0
let shelf = 0
return widgets.map((widget) => {
const { w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
if (x + width > columns) {
x = 0
y += shelf
shelf = 0
}
const item = { i: widget.id, x, y, w: width, h: Math.max(1, h) }
x += width
shelf = Math.max(shelf, item.h)
return item
})
}
function layoutOf(widgets: WidgetDef[], columns: number): Layout {
if (!isPlaced(widgets)) return packed(widgets, columns)
return widgets.map((widget) => {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
return {
i: widget.id,
x: Math.min(columns - width, Math.max(0, x)),
y: Math.max(0, y),
w: width,
h: Math.max(1, h),
}
})
}
const same = (a: Layout, b: Layout) =>
a.length === b.length &&
a.every((item, index) => {
const other = b[index]
return (
other &&
item.i === other.i &&
item.x === other.x &&
item.y === other.y &&
item.w === other.w &&
item.h === other.h
)
})
/**
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
*
* View mode never mounts the grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
*/
export function DashboardEditor({
dashboard,
edit,
}: {
dashboard: Dashboard
edit: boolean
}) {
const [draft, setDraft] = useState<Dashboard>(dashboard)
const [selected, setSelected] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [pageId, setPageId] = useState<string | undefined>(
() => pagesOf(dashboard)[0]?.id,
)
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The saved version is what the next save is based on; without following it
// the second save of a session is always a conflict.
const version = useRef(dashboard.version)
useEffect(() => {
version.current = dashboard.version
}, [dashboard.version])
const commit = (next: Dashboard) => {
setDraft(next)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
save.mutate(
{ ...next, version: version.current },
{
onSuccess: (saved) => {
version.current = saved.version
},
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
},
)
}, AUTOSAVE_MS)
}
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({ to: "/dashboards" })
},
onError: handleError.bind(showErrorToast),
})
const columns = columnsOf(draft)
const pages = pagesOf(draft)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
const section = page ? sectionsOf(page)[0] : undefined
const widgets = section ? widgetsOf(section) : []
const { width, containerRef, mounted } = useContainerWidth()
const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return
commit({
...draft,
pages: pagesOf(draft).map((candidate) =>
candidate.id !== page.id
? candidate
: {
...candidate,
sections: sectionsOf(candidate).map((existing) =>
existing.id !== section.id
? existing
: { ...existing, widgets: next },
),
},
),
})
}
const addWidget = (type: WidgetKind) => {
const id = nextId(draft, type)
const bottom = layoutOf(widgets, columns).reduce(
(lowest, item) => Math.max(lowest, item.y + item.h),
0,
)
updateWidgets([
...widgets,
{
id,
type,
title: WIDGET_LABELS[type],
layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } },
config: type === "chart" ? { series: [{}] } : {},
},
])
setSettingsOpen(false)
setSelected(id)
}
const patch = (id: string, changes: Partial<WidgetDef>) =>
updateWidgets(
widgets.map((widget) =>
widget.id === id ? { ...widget, ...changes } : widget,
),
)
const layout = layoutOf(widgets, columns)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
if (same(layout, next)) return
const byId = new Map(next.map((item) => [item.i, item]))
updateWidgets(
widgets.map((widget) => {
const item = byId.get(widget.id)
if (!item) return widget
const placed: Placement = {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
}
return {
...widget,
layout: { ...(widget.layout ?? {}), lg: placed },
}
}),
)
}
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
const setEdit = (next: boolean) => {
setSelected(null)
setSettingsOpen(false)
navigate({
to: "/dashboards/$name",
params: { name: draft.name },
search: next ? { edit: true } : {},
})
}
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
) : !edit ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : (
<div ref={containerRef}>
{/* Measured first: laying out against a guessed width would place every
widget once and then move it. */}
{mounted ? (
<GridLayout
width={width}
layout={layout}
onLayoutChange={applyLayout}
gridConfig={{
cols: columns,
rowHeight: ROW_HEIGHT,
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
dragConfig={{ handle: ".widget-grip" }}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
</div>
))}
</GridLayout>
) : null}
</div>
)
return (
<>
<div
className={cn(
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
data-testid="dashboard-canvas"
>
{body}
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]",
)}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{draft.title || draft.name}
</span>
{pages.length > 1 ? (
<Tabs value={page?.id} onValueChange={setPageId}>
<TabsList>
{pages.map((candidate) => (
<TabsTrigger key={candidate.id} value={candidate.id}>
{candidate.title || candidate.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
</CanvasTitle>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
{edit ? (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Add widget"
data-testid="add-widget"
>
<Plus />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-2">
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Add a widget
</p>
<div className="mt-1 grid gap-0.5">
{KINDS.map((kind) => (
<button
key={kind}
type="button"
className="rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
{WIDGET_LABELS[kind]}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => {
setSelected(null)
setSettingsOpen(true)
}}
aria-label="Dashboard settings"
data-testid="edit-dashboard"
>
<Settings2 />
</Button>
</TooltipTrigger>
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{save.isPending ? "Saving" : "All changes saved"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Open the panel view"
data-testid="open-view"
asChild
>
<a
href={`/view/${draft.name}`}
target="_blank"
rel="noreferrer"
>
<ExternalLink />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open what a wall panel sees</TooltipContent>
</Tooltip>
<Button
variant={edit ? "brand" : "secondary"}
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
{edit ? (
<Check className="size-4" />
) : (
<Pencil className="size-4" />
)}
{edit ? "Done" : "Edit"}
</Button>
</motion.div>
</div>
{edit ? (
<>
<WidgetPanel
widget={active}
onChange={(changes) => active && patch(active.id, changes)}
onDelete={() => {
if (!active) return
updateWidgets(widgets.filter((other) => other.id !== active.id))
setSelected(null)
}}
onClose={() => setSelected(null)}
/>
<DashboardPanel
open={settingsOpen}
dashboard={draft}
widgetCount={widgets.length}
onChange={(changes) => commit({ ...draft, ...changes })}
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
</>
) : null}
</>
)
}