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,436 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, WidgetDef } from "@/client"
|
||||
import {
|
||||
PANEL_SECTION,
|
||||
PanelTitle,
|
||||
SidePanel,
|
||||
} from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { MAX_SERIES } from "./ChartWidget"
|
||||
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
acceptsDtype,
|
||||
INPUT_WIDGETS,
|
||||
type Series,
|
||||
seriesOf,
|
||||
WIDGET_LABELS,
|
||||
type WidgetKind,
|
||||
widgetIssue,
|
||||
} from "./widgets"
|
||||
|
||||
const config = (widget: WidgetDef) =>
|
||||
(widget.config ?? {}) as Record<string, unknown>
|
||||
|
||||
const str = (value: unknown) => (value == null ? "" : String(value))
|
||||
|
||||
/** Which messages this kind of widget may be pointed at. */
|
||||
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
|
||||
const input = INPUT_WIDGETS.has(kind)
|
||||
return catalog.filter(
|
||||
(message) =>
|
||||
acceptsDtype(kind, message.dtype) &&
|
||||
// Flows own the namespace; a control can only set what one declares.
|
||||
(!input || message.writable !== false),
|
||||
)
|
||||
}
|
||||
|
||||
/** The picker, which records the payload type it bound along with the name. */
|
||||
function MessagePicker({
|
||||
kind,
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
onPick,
|
||||
}: {
|
||||
kind: WidgetKind
|
||||
value: string
|
||||
label: string
|
||||
testId?: string
|
||||
onPick: (message: string, dtype: string) => void
|
||||
}) {
|
||||
const { data } = useQuery(messageCatalogQueryOptions())
|
||||
const choices = choicesFor(kind, data?.data ?? [])
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(next) =>
|
||||
onPick(
|
||||
next,
|
||||
choices.find((message) => message.name === next)?.dtype ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid={testId}>
|
||||
<SelectValue placeholder="Pick a message" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{choices.map((message) => (
|
||||
<SelectItem key={message.name} value={message.name}>
|
||||
{message.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one widget shows or does.
|
||||
*
|
||||
* The same floating panel the flow editor uses for a node, so the two editors
|
||||
* read as one surface.
|
||||
*/
|
||||
export function WidgetPanel({
|
||||
widget,
|
||||
onChange,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
widget: WidgetDef | null
|
||||
onChange: (changes: Partial<WidgetDef>) => void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!widget) return null
|
||||
const cfg = config(widget)
|
||||
const isInput = INPUT_WIDGETS.has(widget.type)
|
||||
const issue = widgetIssue(widget)
|
||||
|
||||
const set = (changes: Record<string, unknown>) =>
|
||||
onChange({ config: { ...cfg, ...changes } })
|
||||
|
||||
const series = seriesOf(widget)
|
||||
const setSeries = (next: Series[]) => set({ series: next })
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
open={Boolean(widget)}
|
||||
label="Widget settings"
|
||||
testId="widget-panel"
|
||||
bodyKey={widget.id}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<PanelTitle
|
||||
value={widget.title ?? ""}
|
||||
placeholder={WIDGET_LABELS[widget.type]}
|
||||
label="Widget title"
|
||||
onConfirm={(title) => onChange({ title })}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
data-testid="delete-widget"
|
||||
>
|
||||
Remove widget
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-4" data-testid="widget-settings">
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>{WIDGET_LABELS[widget.type]}</span>
|
||||
{issue ? (
|
||||
<p className="text-sm text-destructive" data-testid="widget-error">
|
||||
{issue}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{widget.type === "markdown" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Text</Label>
|
||||
<Input
|
||||
value={str(cfg.content)}
|
||||
placeholder="# Heading"
|
||||
onChange={(event) => set({ content: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : widget.type === "chart" ? (
|
||||
<div className="grid gap-2">
|
||||
{series.map((entry, index) => (
|
||||
<div
|
||||
// Position is the only identity a series row has.
|
||||
key={`series-${index}`}
|
||||
className="flex items-end gap-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
kind="chart"
|
||||
value={entry.message ?? ""}
|
||||
label={index === 0 ? "Draws" : ""}
|
||||
testId={index === 0 ? "widget-message" : undefined}
|
||||
onPick={(message, dtype) =>
|
||||
setSeries(
|
||||
series.map((other, at) =>
|
||||
at === index ? { ...other, message, dtype } : other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove series"
|
||||
onClick={() =>
|
||||
setSeries(series.filter((_, at) => at !== index))
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{series.length < MAX_SERIES ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
onClick={() => setSeries([...series, {}])}
|
||||
data-testid="add-series"
|
||||
>
|
||||
<Plus />
|
||||
Add series
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<MessagePicker
|
||||
kind={widget.type}
|
||||
value={str(cfg[isInput ? "target" : "message"])}
|
||||
label={isInput ? "Publishes to" : "Shows"}
|
||||
testId="widget-message"
|
||||
onPick={(message, dtype) =>
|
||||
set({ [isInput ? "target" : "message"]: message, dtype })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{widget.type === "chart" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Points kept</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(
|
||||
(cfg.history as { points?: number } | undefined)?.points ?? 300,
|
||||
)}
|
||||
onChange={(event) =>
|
||||
set({ history: { points: Number(event.target.value) || 0 } })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How much past the engine keeps for these messages.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "stat" || widget.type === "gauge" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Unit</Label>
|
||||
<Input
|
||||
value={str(cfg.unit)}
|
||||
placeholder="°C"
|
||||
onChange={(event) => set({ unit: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "gauge" || widget.type === "slider" ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Minimum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(cfg.min ?? 0)}
|
||||
onChange={(event) =>
|
||||
set({ min: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-sm font-normal">Maximum</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(cfg.max ?? 100)}
|
||||
onChange={(event) =>
|
||||
set({ max: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "button" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Sends</Label>
|
||||
<Input
|
||||
value={str(cfg.value)}
|
||||
placeholder="true"
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value
|
||||
const asNumber = Number(raw)
|
||||
set({
|
||||
value:
|
||||
raw === "true" || raw === "false"
|
||||
? raw === "true"
|
||||
: raw !== "" && Number.isFinite(asNumber)
|
||||
? asNumber
|
||||
: raw,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's own settings, in the panel its widgets use.
|
||||
*
|
||||
* The grid size is the one that matters: a wall panel is a fixed width, and
|
||||
* twelve columns on a seven-inch screen is a different dashboard than twelve
|
||||
* on a television.
|
||||
*/
|
||||
export function DashboardPanel({
|
||||
open,
|
||||
dashboard,
|
||||
widgetCount,
|
||||
onChange,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
dashboard: Dashboard
|
||||
widgetCount: number
|
||||
onChange: (changes: Partial<Dashboard>) => void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanel
|
||||
open={open}
|
||||
label="Dashboard settings"
|
||||
testId="dashboard-panel"
|
||||
bodyKey={dashboard.name}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<PanelTitle
|
||||
value={dashboard.title ?? ""}
|
||||
placeholder={dashboard.name}
|
||||
label="Dashboard title"
|
||||
onConfirm={(title) => onChange({ title })}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
data-testid="delete-dashboard"
|
||||
>
|
||||
Delete dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-4">
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Grid</span>
|
||||
<Select
|
||||
value={String(columnsOf(dashboard))}
|
||||
onValueChange={(value) => onChange({ columns: Number(value) })}
|
||||
>
|
||||
<SelectTrigger data-testid="dashboard-columns">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_CHOICES.map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>
|
||||
{count} columns
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How many columns wide this dashboard is laid out, so it can be
|
||||
matched to the panel it will hang on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{dashboard.name}</span> —{" "}
|
||||
{widgetCount === 0
|
||||
? "nothing on it yet."
|
||||
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Delete {dashboard.title || dashboard.name}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the dashboard and its widgets. The flows it read from
|
||||
are untouched, and its history stays in the flow store's git
|
||||
repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
data-testid="confirm-delete-dashboard"
|
||||
>
|
||||
Delete dashboard
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user