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.
1280 lines
43 KiB
TypeScript
1280 lines
43 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|