Files
app/frontend/src/components/Dashboard/panels.tsx
T
stroblme 1002241258 Add the dashboard settings channel, wired for theme and lock
A dashboard could only ever receive as a set of tiles. This adds the dashboard
itself as a receiver: `settings` maps a name to a value plus an optional
binding. Unbound, the setting is simply its value — a wall panel that is always
dark costs no flow. Bound, a flow drives it live and the value is the fallback.

Two settings are wired: `theme` (system/light/dark) and `locked` (read-only).
There is no schedule field on purpose — a node publishing to the bound message
on a cron is what a schedule is here, which is the point of a channel.

- `messages_for()` now walks a dashboard's bound settings as well as its
  widgets' bindings. Without this a paired screen is refused its own theme
  message, on the one surface the setting exists for; it bounds the socket too.
- `locked` is gated in `usePublish`, so every control inherits it, and each
  control also draws itself disabled — a dead button reads as broken otherwise.
  The panel surface says Read-only in the corner.
- The theme is a class on the dashboard's own surface, never the root: inside
  the app shell it must not flip the chrome. `.light` gains the tokens `.dark`
  already had (mirrored in the index repo) so both directions work on a subtree.
- Settings bindings are type-checked from the document alone, the rule widget
  bindings follow, and mirrored on the server.
- A bound setting is drawn on the flow canvas as a dashboard-level endpoint.
- The demo's house flow now publishes `home.panel_theme`, which the demo
  dashboard's theme binds to: the panel goes dark after sunset, at no tile cost.
2026-08-22 13:17:56 +02:00

1280 lines
43 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 { Ban, ChevronDown, Plus, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, SettingDef, 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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Segmented } from "@/components/ui/segmented"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
canvasOf,
columnsOf,
type Dashboard,
} from "./DashboardView"
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries"
import {
SETTING_DTYPES,
type SettingName,
settingIssue,
settingOf,
THEME_CHOICES,
} from "./settings"
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,
placeholder = "Pick a message",
filter,
onPick,
}: {
/** Omitted where the slot is not a widget's at all — a dashboard setting
* binds by payload type alone, and hands in `filter` instead. */
kind?: WidgetKind
value: string
label: string
testId?: string
placeholder?: 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)
: kind
? choicesFor(kind, catalog)
: 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={placeholder} />
</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. */
const CHART_SOURCES = [
["live", "Live"],
["query", "Query"],
] as const
/**
* Pick one of the tile glyphs, or none.
*
* A grid of the glyphs themselves rather than a list of their names: an icon is
* chosen by how it looks. Clearing is a button rather than an option, because
* Radix forbids an empty `SelectItem` value — which is why the selects this
* replaces could set an icon but never take one back.
*
* ponytail: no filter field. `ICONS` is a few dozen and the grid shows all of
* it without scrolling; add one when the map outgrows a popover.
*/
function IconPicker({
value,
placeholder,
label,
testId,
className,
onChange,
}: {
value: string
/** What no icon gets you, on the trigger and on the clearing button. */
placeholder: string
/** Names the trigger for screen readers. */
label: string
testId?: string
className?: string
onChange: (icon: string) => void
}) {
const [open, setOpen] = useState(false)
const Current = ICONS[value]
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
// Sits in a row of fields, so it wears their border and surface
// rather than a button's.
className={cn(
"justify-start border-input bg-transparent font-normal",
className,
)}
aria-label={label}
data-testid={testId}
>
{Current ? <Current /> : null}
<span className={cn("truncate", !value && "text-muted-foreground")}>
{value || placeholder}
</span>
<ChevronDown className="ml-auto opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1">
{ICON_NAMES.map((name) => {
const Glyph = ICONS[name]
return (
<Button
key={name}
variant="ghost"
size="icon-sm"
title={name}
aria-label={name}
aria-pressed={name === value}
className={cn(
name === value && "bg-accent text-accent-foreground",
)}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Glyph />
</Button>
)
})}
</div>
<Button
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
data-testid={testId ? `${testId}-clear` : undefined}
onClick={() => {
onChange("")
setOpen(false)
}}
>
<Ban />
{placeholder}
</Button>
</PopoverContent>
</Popover>
)
}
/** 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">
<Segmented
value={querying ? "query" : "live"}
options={CHART_SOURCES}
label="Where the chart's data comes from"
testId="chart-source"
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"
// A colour widget may bind either shape, and the format is what
// decides which of the two — so it filters rather than the type.
filter={
widget.type === "color"
? (message) =>
message.dtype === COLOR_DTYPES[colorFormatOf(widget)] &&
message.writable !== false
: undefined
}
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 flex-wrap 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,
),
)
}
/>
<IconPicker
value={rule.icon ?? ""}
placeholder="No icon"
label="Rule icon"
className="min-w-0 flex-1"
onChange={(icon) =>
setRules(
rules.map((other, at) =>
at === index ? { ...other, icon } : other,
),
)
}
/>
<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>
<Input
className="basis-full"
value={rule.label ?? ""}
placeholder="Caption under the glyph"
aria-label="Rule label"
onChange={(event) =>
setRules(
rules.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
</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>
<IconPicker
value={str(cfg.icon)}
placeholder="Nothing"
label="Icon when no rule matches"
className="w-full"
onChange={(icon) => set({ icon })}
/>
</div>
</div>
) : null}
{widget.type === "color" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Segmented
value={colorFormatOf(widget)}
options={COLOR_FORMATS}
label="What this wheel publishes"
testId="widget-format"
onChange={(format) =>
set({
format,
// Both triples are a `list` and hex is a `str`, so a change
// between the two shapes takes the binding with it rather
// than leaving a message this control can no longer carry.
...(COLOR_DTYPES[format as keyof typeof COLOR_DTYPES] ===
str(cfg.dtype)
? {}
: { target: "", dtype: undefined }),
})
}
/>
<p className="text-xs text-muted-foreground">
HSV is{" "}
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>,
RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
<span className="font-mono">"#rrggbb"</span>.
</p>
</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" ? (
<Segmented
value={str(cfg.style) || "track"}
options={[
["track", "Track"],
["button", "Button"],
]}
label="How this control is drawn"
testId="widget-style"
onChange={(style) => set({ style })}
/>
) : (
<Segmented
value={str(cfg.style) || "list"}
options={[
["list", "List"],
["segmented", "Segmented"],
]}
label="How this control is drawn"
testId="widget-style"
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 optional half of a setting: which message, if any, drives it.
*
* Deliberately drawn as an addition rather than a requirement — the placeholder
* says what leaving it alone means, and the value control above it is the whole
* setting until something is picked here. Binding is how a flow takes the
* setting over; a node publishing on a cron is what a schedule is in this
* system, so there is no scheduling UI to build.
*
* The picker records the payload type beside the name, which is what lets the
* pairing be judged from the document alone — the same rule a widget's binding
* is held to, and the same one the server enforces.
*/
function SettingBinding({
name,
setting,
onChange,
}: {
name: SettingName
setting: SettingDef
onChange: (setting: SettingDef) => void
}) {
const want = SETTING_DTYPES[name]
const issue = settingIssue(name, setting)
return (
<div className="grid gap-1.5">
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<MessagePicker
value={str(setting.message)}
label="Driven by"
testId={`dashboard-${name}-message`}
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
filter={(message) => message.dtype === want}
onPick={(message, dtype) =>
onChange({ ...setting, message, dtype })
}
/>
</div>
{setting.message ? (
<Button
variant="ghost"
size="icon"
aria-label={`Stop driving ${name}`}
data-testid={`dashboard-${name}-unbind`}
onClick={() => onChange({ value: setting.value })}
>
<X />
</Button>
) : null}
</div>
{issue ? (
<p className="text-sm text-destructive" role="alert">
{issue}
</p>
) : null}
</div>
)
}
/** How a setting's own value reads in the "nothing is driving it" line. */
function valueLabel(name: SettingName, value: unknown): string {
if (name === "locked") return value === true ? "read-only" : "editable"
const chosen = THEME_CHOICES.find(([option]) => option === value)
return (chosen?.[1] ?? "System").toLowerCase()
}
/**
* 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)
const theme = settingOf(dashboard, "theme")
const locked = settingOf(dashboard, "locked")
/** Settings are a map, so one of them changing rewrites the whole of it. */
const setSetting = (name: SettingName, setting: SettingDef) =>
onChange({ settings: { ...(dashboard.settings ?? {}), [name]: setting } })
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}>Rail icon</span>
<IconPicker
value={str(dashboard.icon)}
placeholder="Two letters of the title"
label="Rail icon"
testId="dashboard-icon"
className="w-full"
onChange={(icon) => onChange({ icon })}
/>
<p className="text-sm text-muted-foreground">
Drawn on the rail when a panel carries more than one dashboard.
</p>
</div>
<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}>Theme</span>
<Segmented
value={str(theme.value) || "system"}
options={THEME_CHOICES}
label="Dashboard theme"
testId="dashboard-theme"
onChange={(value) => setSetting("theme", { ...theme, value })}
/>
<SettingBinding
name="theme"
setting={theme}
onChange={(setting) => setSetting("theme", setting)}
/>
<p className="text-sm text-muted-foreground">
What this dashboard wears wherever it is shown — a screen on a
wall has nobody to set the device preference System otherwise
follows. Bind a message and a flow drives it instead: a node
publishing on a cron is what a schedule looks like here, and the
choice above stays the fallback.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Lock</span>
<div className="flex items-center justify-between gap-2 text-sm">
Read-only
<Switch
checked={locked.value === true}
aria-label="Read-only"
data-testid="dashboard-lock"
onCheckedChange={(value) =>
setSetting("locked", { ...locked, value })
}
/>
</div>
<SettingBinding
name="locked"
setting={locked}
onChange={(setting) => setSetting("locked", setting)}
/>
<p className="text-sm text-muted-foreground">
Locked, the controls on this dashboard are shown but stop
publishing, and the surface says so. It is a read-only surface
rather than a permission: what a paired screen may reach is still
decided by its own credential.
</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>
</>
)
}