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.
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { useQuery } from "@tanstack/react-query"
|
|
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react"
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
import type { ApiError, DashboardDef_Output, WidgetDef } from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { handleError } from "@/utils"
|
|
import {
|
|
pagesOf,
|
|
SectionGrid,
|
|
sectionsOf,
|
|
widgetStyle,
|
|
widgetsOf,
|
|
} from "./DashboardView"
|
|
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
|
|
import {
|
|
INPUT_WIDGETS,
|
|
WIDGET_LABELS,
|
|
WIDGET_SIZES,
|
|
WidgetBody,
|
|
WidgetFrame,
|
|
type WidgetKind,
|
|
} from "./widgets"
|
|
|
|
// Charts are stored and validated, but not drawn yet, so they are not offered.
|
|
const KINDS = (Object.keys(WIDGET_LABELS) as WidgetKind[]).filter(
|
|
(kind) => kind !== "chart",
|
|
)
|
|
|
|
/** How long to sit on edits before saving, so typing is not a save per key. */
|
|
const AUTOSAVE_MS = 800
|
|
|
|
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
|
|
}
|
|
|
|
export function DashboardEditor({
|
|
dashboard,
|
|
pageId,
|
|
}: {
|
|
dashboard: DashboardDef_Output
|
|
pageId?: string
|
|
}) {
|
|
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
|
|
const [selected, setSelected] = useState<string | null>(null)
|
|
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: DashboardDef_Output) => {
|
|
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 pages = pagesOf(draft)
|
|
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
|
if (!page) return null
|
|
const section = sectionsOf(page)[0]
|
|
if (!section) return null
|
|
const widgets = widgetsOf(section)
|
|
|
|
const updateWidgets = (next: WidgetDef[]) =>
|
|
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)
|
|
updateWidgets([
|
|
...widgets,
|
|
{
|
|
id,
|
|
type,
|
|
title: WIDGET_LABELS[type],
|
|
layout: { lg: { x: 0, y: 0, ...WIDGET_SIZES[type] } },
|
|
config: {},
|
|
},
|
|
])
|
|
setSelected(id)
|
|
}
|
|
|
|
const patch = (id: string, changes: Partial<WidgetDef>) =>
|
|
updateWidgets(
|
|
widgets.map((widget) =>
|
|
widget.id === id ? { ...widget, ...changes } : widget,
|
|
),
|
|
)
|
|
|
|
const resize = (widget: WidgetDef, by: number) => {
|
|
const layout = (widget.layout ?? {}) as Record<
|
|
string,
|
|
{ x?: number; y?: number; w?: number; h?: number }
|
|
>
|
|
const current = layout.lg ?? { x: 0, y: 0, w: 3, h: 2 }
|
|
patch(widget.id, {
|
|
layout: {
|
|
...layout,
|
|
lg: { ...current, w: Math.min(12, Math.max(2, (current.w ?? 3) + by)) },
|
|
},
|
|
})
|
|
}
|
|
|
|
const active = widgets.find((widget) => widget.id === selected)
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">Add</span>
|
|
{KINDS.map((kind) => (
|
|
<Button
|
|
key={kind}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
data-testid={`add-widget-${kind}`}
|
|
onClick={() => addWidget(kind)}
|
|
>
|
|
<Plus />
|
|
{WIDGET_LABELS[kind]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<SectionGrid
|
|
section={section}
|
|
renderWidget={(widget) => (
|
|
<WidgetFrame
|
|
title={widget.title}
|
|
className={
|
|
widget.id === selected ? "ring-2 ring-primary" : undefined
|
|
}
|
|
actions={
|
|
<div className="flex items-center gap-0.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Narrower"
|
|
onClick={() => resize(widget, -1)}
|
|
>
|
|
<ChevronLeft />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Wider"
|
|
onClick={() => resize(widget, 1)}
|
|
>
|
|
<ChevronRight />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove widget"
|
|
onClick={() =>
|
|
updateWidgets(
|
|
widgets.filter((other) => other.id !== widget.id),
|
|
)
|
|
}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="min-h-0 flex-1 text-left"
|
|
onClick={() => setSelected(widget.id)}
|
|
>
|
|
<WidgetBody widget={widget} />
|
|
</button>
|
|
</WidgetFrame>
|
|
)}
|
|
/>
|
|
|
|
{active ? (
|
|
<WidgetSettings
|
|
widget={active}
|
|
onChange={(changes) => patch(active.id, changes)}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Add a widget, or pick one to configure it.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** What one widget shows or does. Fields differ per type; the set is small. */
|
|
function WidgetSettings({
|
|
widget,
|
|
onChange,
|
|
}: {
|
|
widget: WidgetDef
|
|
onChange: (changes: Partial<WidgetDef>) => void
|
|
}) {
|
|
const { data: catalog } = useQuery(messageCatalogQueryOptions())
|
|
const messages = catalog?.data ?? []
|
|
const config = (widget.config ?? {}) as Record<string, unknown>
|
|
const isInput = INPUT_WIDGETS.has(widget.type)
|
|
const numericOnly = widget.type === "gauge" || widget.type === "chart"
|
|
|
|
const set = (key: string, value: unknown) =>
|
|
onChange({ config: { ...config, [key]: value } })
|
|
|
|
const choices = messages.filter((message) =>
|
|
numericOnly ? message.numeric : true,
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
|
|
data-testid="widget-settings"
|
|
>
|
|
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
{WIDGET_LABELS[widget.type]} settings
|
|
</span>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">Title</Label>
|
|
<Input
|
|
value={widget.title ?? ""}
|
|
onChange={(event) => onChange({ title: event.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{widget.type === "markdown" ? (
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">Text</Label>
|
|
<Input
|
|
value={String(config.content ?? "")}
|
|
placeholder="# Heading"
|
|
onChange={(event) => set("content", event.target.value)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">
|
|
{isInput ? "Publishes to" : "Shows"}
|
|
</Label>
|
|
<Select
|
|
value={String(config[isInput ? "target" : "message"] ?? "")}
|
|
onValueChange={(value) =>
|
|
set(isInput ? "target" : "message", value)
|
|
}
|
|
>
|
|
<SelectTrigger data-testid="widget-message">
|
|
<SelectValue placeholder="Pick a message" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{choices.map((message) => (
|
|
<SelectItem key={message.name} value={message.name}>
|
|
{message.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{(widget.type === "stat" || widget.type === "gauge") && (
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">Unit</Label>
|
|
<Input
|
|
value={String(config.unit ?? "")}
|
|
placeholder="°C"
|
|
onChange={(event) => set("unit", event.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{(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={String(config.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={String(config.max ?? 100)}
|
|
onChange={(event) => set("max", Number(event.target.value) || 0)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{widget.type === "button" && (
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">Sends</Label>
|
|
<Input
|
|
value={String(config.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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { widgetStyle }
|