Files
app/frontend/src/components/Dashboard/panels.tsx
T
stroblmeandClaude Opus 5 fe1ba46d4e Stack a bar's readings, and stop widgets taking the phone sideways
A bar drew its nested reading on top of the outer one in --chart-5, which
measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The
readings now partition the fill end to end, up to three of them, in a token of
their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light
and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two
are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment
is drawn inside a gutter of outer fill rather than ever bordering the track,
which is what separates neighbours too, and what caps the count at three. A
nested value larger than its outer used to spill onto the track; it is clamped.
`inner` still reads as a single binding, so no dashboard needs migrating.

On a phone, .widget-grid took its width from the widest thing any widget held —
a truncating flex item still offers its whole unwrapped line as a min-content
contribution — and a handful of widgets had no floor of their own: the uPlot
legend is a table, a fieldset carries min-inline-size: min-content from the UA
sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body
scrolls rather than clipping, so long text stops painting over the title.

Gauges and bars move between readings instead of jumping, and a segmented
control slides one thumb rather than recolouring cells. The gauge arc is drawn
whole and revealed by its dash, because `d` cannot be transitioned.

UplotChart pushed new readings only when the point count changed, so once a
rolling window was full a refetch left the old values on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 10:11:44 +02:00

1057 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from "@tanstack/react-query"
import { Plus, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
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 { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
canvasOf,
columnsOf,
type Dashboard,
} from "./DashboardView"
import { ICON_COLORS, ICON_NAMES } from "./icons"
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,
filter,
onPick,
}: {
kind: WidgetKind
value: string
label: string
testId?: string
/** What this slot takes, when the widget's own type does not decide it —
* a querying chart asks with one shape and draws another. */
filter?: (message: MessageInfo) => boolean
onPick: (message: string, dtype: string) => void
}) {
const { data } = useQuery(messageCatalogQueryOptions())
const catalog = data?.data ?? []
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog)
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>
)
}
/**
* Where a chart's lines come from: what the engine kept, or what it asks for.
*
* The one segmented shape — a single border pill, transparent segments,
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
*/
function ModePicker({
value,
onChange,
}: {
value: "live" | "query"
onChange: (mode: "live" | "query") => void
}) {
return (
<fieldset
data-testid="chart-source"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">Where the chart's data comes from</legend>
{(
[
["live", "Live"],
["query", "Query"],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
aria-pressed={value === mode}
onClick={() => onChange(mode)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === mode
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/** Which chrome an input wears, in the same segmented shape as the mode. */
function StylePicker({
value,
options,
onChange,
}: {
value: string
options: readonly (readonly [string, string])[]
onChange: (style: string) => void
}) {
return (
<fieldset
data-testid="widget-style"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">How this control is drawn</legend>
{options.map(([style, label]) => (
<button
key={style}
type="button"
aria-pressed={value === style}
onClick={() => onChange(style)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === style
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/** What a text field was meant to send: a bool, a number, or the text itself. */
function coerce(raw: string): unknown {
const asNumber = Number(raw)
if (raw === "true" || raw === "false") return raw === "true"
return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw
}
/**
* 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 })
// A bar's nested readings. An empty row stands in for none, so an unnested
// bar still offers the picker rather than only a button.
const segments = segmentsOf(widget)
const rows: Segment[] = segments.length ? segments : [{}]
// Always written as a list; `inner_dtype` belonged to the single binding a
// bar carried before it stacked, and goes with it.
const setSegments = (next: Segment[]) =>
set({ inner: next, inner_dtype: undefined })
// The icon widget's mapping. Position is the row's identity, as with series.
const rules = (cfg.rules ?? []) as {
at?: unknown
icon?: string
color?: string
label?: string
}[]
const setRules = (next: typeof rules) => set({ rules: next })
// Nothing wrote these until now, so a dropdown's choices were uneditable.
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
const paced = refreshFor(
RANGES.find((range) => range.hours * 3600 === Number(cfg.range_s)) ??
DEFAULT_RANGE,
0,
)
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-3">
<ModePicker
value={querying ? "query" : "live"}
onChange={(source) => set({ source })}
/>
{querying ? (
<div className="grid gap-2">
<MessagePicker
kind="chart"
value={str(cfg.request)}
label="Asks"
testId="widget-request"
filter={(message) =>
message.dtype === "record" && message.writable !== false
}
onPick={(request, request_dtype) =>
set({ request, request_dtype })
}
/>
<MessagePicker
kind="chart"
value={str(cfg.message)}
label="Draws"
testId="widget-message"
filter={(message) => message.dtype === "series"}
onPick={(message, dtype) => set({ message, dtype })}
/>
</div>
) : (
<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>
<Input
className="w-28"
value={entry.label ?? ""}
placeholder="Label"
aria-label="Series label"
onChange={(event) =>
setSeries(
series.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<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>
)}
</div>
) : // A clock reads the wall; a picker would bind a message nothing reads.
widget.type === "clock" ? null : (
<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 === "bar" ? (
<div className="grid gap-2">
{rows.map((segment, index) => (
<div
// Position is the only identity a segment row has, as with series.
key={`segment-${index}`}
className="flex items-end gap-1.5"
>
<div className="min-w-0 flex-1">
<MessagePicker
kind="bar"
value={segment.message ?? ""}
label={index === 0 ? "Nested bar" : ""}
testId={index === 0 ? "widget-inner" : undefined}
onPick={(message, dtype) =>
setSegments(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove segment"
onClick={() =>
setSegments(rows.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
{rows.length < MAX_SEGMENTS ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSegments([...rows, {}])}
data-testid="add-segment"
>
<Plus />
Add segment
</Button>
) : null}
</div>
) : null}
{widget.type === "chart" && !querying ? (
<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 === "chart" && querying ? (
<>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Refresh, seconds</Label>
<Input
type="number"
min={paced}
placeholder={str(paced)}
value={str(cfg.refresh_s ?? "")}
onChange={(event) =>
set({
refresh_s:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
<p className="text-xs text-muted-foreground">
Empty follows the window {paced} seconds at this range, since
nothing changes until the bucket closes. A slower one is kept, a
faster one only asks for the same picture twice.
</p>
</div>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Range shown first</Label>
<Select
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
onValueChange={(value) => set({ range_s: Number(value) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGES.map((range) => (
<SelectItem
key={range.label}
value={String(range.hours * 3600)}
>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
A viewer can pick another on the widget itself.
</p>
</div>
</>
) : null}
{widget.type === "agenda" || widget.type === "forecast" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Items shown</Label>
<Input
type="number"
min={1}
value={str(cfg.count ?? 5)}
onChange={(event) =>
set({ count: Number(event.target.value) || 0 })
}
/>
</div>
) : null}
{widget.type === "chart" ? (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y minimum</Label>
<Input
type="number"
value={str(cfg.y_min ?? "")}
placeholder="auto"
onChange={(event) =>
set({
y_min:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y maximum</Label>
<Input
type="number"
value={str(cfg.y_max ?? "")}
placeholder="auto"
onChange={(event) =>
set({
y_max:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y axis title</Label>
<Input
value={str(cfg.y_label)}
placeholder="kW"
onChange={(event) => set({ y_label: event.target.value })}
/>
</div>
</div>
) : null}
{widget.type === "stat" ||
widget.type === "gauge" ||
widget.type === "chart" ||
widget.type === "bar" ||
widget.type === "slider" ? (
<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" ||
widget.type === "bar" ? (
<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>
{widget.type === "slider" ? (
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Step</Label>
<Input
type="number"
min={0}
step="any"
value={str(cfg.step ?? 1)}
onChange={(event) =>
set({ step: Number(event.target.value) || 1 })
}
/>
</div>
) : null}
</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) => set({ value: coerce(event.target.value) })}
/>
</div>
) : null}
{widget.type === "dropdown" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Options</span>
{options.map((option, index) => (
<div
// Position is the only identity an option row has.
key={`option-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(option.label)}
placeholder="Label"
aria-label="Option label"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(option.value)}
placeholder="Value"
aria-label="Option value"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove option"
onClick={() =>
setOptions(options.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setOptions([...options, {}])}
data-testid="add-option"
>
<Plus />
Add option
</Button>
</div>
) : null}
{widget.type === "icon" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Mapping</span>
{rules.map((rule, index) => (
<div
// Position is the only identity a rule row has.
key={`rule-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="w-20"
value={str(rule.at)}
placeholder="Value"
aria-label="Rule value"
onChange={(event) =>
setRules(
rules.map((other, at) =>
at === index
? { ...other, at: coerce(event.target.value) }
: other,
),
)
}
/>
<Select
value={rule.icon ?? ""}
onValueChange={(icon) =>
setRules(
rules.map((other, at) =>
at === index ? { ...other, icon } : other,
),
)
}
>
<SelectTrigger
className="min-w-0 flex-1"
aria-label="Rule icon"
>
<SelectValue placeholder="Icon" />
</SelectTrigger>
<SelectContent>
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={rule.color ?? "default"}
onValueChange={(color) =>
setRules(
rules.map((other, at) =>
at === index ? { ...other, color } : other,
),
)
}
>
<SelectTrigger className="w-28" aria-label="Rule colour">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(ICON_COLORS).map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove rule"
onClick={() =>
setRules(rules.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setRules([...rules, {}])}
data-testid="add-icon-rule"
>
<Plus />
Add value
</Button>
<p className="text-xs text-muted-foreground">
Rows are checked top to bottom and the first match wins. A number
also matches anything above it, so a descending ladder reads as
thresholds.
</p>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Otherwise</Label>
<Select
value={str(cfg.icon)}
onValueChange={(icon) => set({ icon })}
>
<SelectTrigger>
<SelectValue placeholder="Nothing" />
</SelectTrigger>
<SelectContent>
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? (
<StylePicker
value={str(cfg.style) || "track"}
options={[
["track", "Track"],
["button", "Button"],
]}
onChange={(style) => set({ style })}
/>
) : (
<StylePicker
value={str(cfg.style) || "list"}
options={[
["list", "List"],
["segmented", "Segmented"],
]}
onChange={(style) => set({ style })}
/>
)}
</div>
) : null}
</div>
</SidePanel>
)
}
/** Presets are matched on their size, so a custom one simply matches none. */
const sizeKey = (size: { width: number; height: number }) =>
`${size.width}x${size.height}`
/**
* The dashboard's own settings, in the panel its widgets use.
*
* The canvas is the one that matters: a wall panel is a fixed size, 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)
const canvas = canvasOf(dashboard)
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}>Canvas</span>
<Select
value={
CANVAS_PRESETS.some(
(preset) => sizeKey(preset) === sizeKey(canvas),
)
? sizeKey(canvas)
: ""
}
onValueChange={(value) => {
const preset = CANVAS_PRESETS.find(
(candidate) => sizeKey(candidate) === value,
)
if (preset)
onChange({
canvas_width: preset.width,
canvas_height: preset.height,
})
}}
>
<SelectTrigger data-testid="dashboard-canvas-size">
<SelectValue placeholder="Custom" />
</SelectTrigger>
<SelectContent>
{CANVAS_PRESETS.map((preset) => (
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
{preset.label} {preset.width}×{preset.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
type="number"
aria-label="Canvas width"
data-testid="canvas-width"
value={canvas.width}
onChange={(event) =>
onChange({ canvas_width: Number(event.target.value) || 0 })
}
/>
<Input
type="number"
aria-label="Canvas height"
data-testid="canvas-height"
value={canvas.height}
onChange={(event) =>
onChange({ canvas_height: Number(event.target.value) || 0 })
}
/>
</div>
<p className="text-sm text-muted-foreground">
The panel this is drawn for, in pixels. Editing and viewing both
scale that surface to fit, so the arrangement is the same
everywhere.
</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>
</>
)
}