Files
app/frontend/src/components/Dashboard/panels.tsx
T

1762 lines
59 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, RotateCw, 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 { runOverviewQueryOptions } from "@/components/Runs/queries"
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_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 {
LOOK_CHOICES,
lookOf,
SETTING_DTYPES,
type SettingName,
settingIssue,
settingOf,
THEME_CHOICES,
} from "./settings"
import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config"
import { parsePalette, roleLabel } from "./ui/core/theme"
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))
/** A number field left blank inherits rather than reading as zero. */
const numberOrNone = (raw: string) =>
raw.trim() === "" ? undefined : Number(raw)
/** 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>
)
}
/** Which runs a pinned chart draws, and which of their metrics. */
type RunsPick = {
metric?: string
flow?: string
group?: string
ids?: string[]
latest?: number
}
/** How a pinned chart says which runs it means. */
const RUN_PICKS = [
["latest", "Latest"],
["group", "Sweep"],
["ids", "Named"],
] as const
/**
* The runs a chart is pinned to.
*
* The flows offered are the ones that have actually run — a flow with no runs
* has no curve to draw, and the run tables already know which those are.
*/
function RunsSourceFields({
runs,
refreshS,
onChange,
onRefresh,
}: {
runs: RunsPick
refreshS: unknown
onChange: (next: RunsPick) => void
onRefresh: (seconds: number) => void
}) {
const { data: flows } = useQuery(runOverviewQueryOptions())
const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest"
return (
<div className="grid gap-2">
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Of flow</Label>
<Select
value={runs.flow ?? ""}
onValueChange={(flow) => onChange({ ...runs, flow })}
>
<SelectTrigger data-testid="runs-flow">
<SelectValue placeholder="Pick a flow" />
</SelectTrigger>
<SelectContent>
{(flows ?? []).map((row) => (
<SelectItem key={row.flow} value={row.flow}>
{row.flow}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MessagePicker
kind="chart"
value={runs.metric ?? ""}
label="Draws"
testId="runs-metric"
placeholder="Pick a metric"
filter={(message) =>
(message.dtype === "float" || message.dtype === "int") &&
(!runs.flow || message.name.startsWith(`${runs.flow}.`))
}
onPick={(metric) => onChange({ ...runs, metric })}
/>
<Segmented
value={mode}
options={RUN_PICKS}
label="Which runs"
testId="runs-pick"
onChange={(picked) =>
onChange({
...runs,
group: undefined,
ids: undefined,
latest: picked === "latest" ? (runs.latest ?? 3) : undefined,
})
}
/>
{mode === "latest" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">How many</Label>
<Input
type="number"
min={1}
max={MAX_SERIES}
value={runs.latest ?? 3}
aria-label="How many runs"
onChange={(event) =>
onChange({ ...runs, latest: Number(event.target.value) })
}
/>
</div>
)}
{mode === "group" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Sweep</Label>
<Input
value={runs.group ?? ""}
placeholder="A sweep's group id"
onChange={(event) =>
onChange({ ...runs, group: event.target.value })
}
/>
</div>
)}
{mode === "ids" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Runs</Label>
<Input
value={(runs.ids ?? []).join(",")}
placeholder="Run ids, comma separated"
onChange={(event) =>
onChange({
...runs,
ids: event.target.value
.split(",")
.map((one) => one.trim())
.filter(Boolean),
})
}
/>
</div>
)}
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Refresh (seconds)</Label>
<Input
type="number"
min={15}
value={Number(refreshS) || 60}
aria-label="Refresh seconds"
onChange={(event) => onRefresh(Number(event.target.value))}
/>
</div>
</div>
)
}
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
const CHART_SOURCES = [
["live", "Live"],
["query", "Query"],
["runs", "Runs"],
] 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 runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next })
// A bar's readings. An empty row stands in for none, so a fresh bar offers
// the picker rather than only a button.
const bound = rowsOf(widget)
const rows: BarRow[] = bound.length ? bound : [{}]
/** Writing rows is also what retires the shape a bar was stored in before. */
const setRows = (next: BarRow[]) =>
set({
rows: next,
message: undefined,
dtype: undefined,
inner: undefined,
inner_dtype: undefined,
inner_label: 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"
const pinnedRuns = cfg.source === "runs"
// 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}
{/* The title is still the widget's name — what a screen reader calls
its controls, and what a publish is labelled with. This is only
whether the panel draws it: a row of gauges under one heading
reads better without four repeated captions above them. */}
<div className="flex items-center justify-between gap-2 text-sm">
Show title
<Switch
checked={showTitle(widget)}
aria-label="Show title"
data-testid="widget-show-title"
onCheckedChange={(value) => set({ show_title: value })}
/>
</div>
{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={pinnedRuns ? "runs" : querying ? "query" : "live"}
options={CHART_SOURCES}
label="Where the chart's data comes from"
testId="chart-source"
onChange={(source) => set({ source })}
/>
{pinnedRuns ? (
<RunsSourceFields
runs={runsPick}
refreshS={cfg.refresh_s}
onChange={(runs) => set({ runs })}
onRefresh={(refresh_s) => set({ refresh_s })}
/>
) : 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. A bar binds a row at a time, below.
widget.type === "clock" || widget.type === "bar" ? 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-3">
{rows.map((row, index) => (
<div
// Position is the only identity a row has, as with series.
key={`row-${index}`}
className="grid gap-1.5"
>
<div className="flex items-end gap-1.5">
<div className="min-w-0 flex-1">
<MessagePicker
kind="bar"
value={row.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setRows(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Input
className="w-28"
value={row.label ?? ""}
placeholder="Label"
aria-label="Row label"
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove row"
onClick={() =>
setRows(rows.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
{/* Blank inherits the widget's own scale below, which is what
a bar of comparable readings wants. A row that measures
something else — a percentage beside a load in kW — says
so here. */}
<div className="flex gap-1.5">
<Input
type="number"
className="w-20"
placeholder="Min"
aria-label="Row minimum"
value={row.min === undefined ? "" : String(row.min)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
min: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
type="number"
className="w-20"
placeholder="Max"
aria-label="Row maximum"
value={row.max === undefined ? "" : String(row.max)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
max: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
className="min-w-0 flex-1"
placeholder="Unit"
aria-label="Row unit"
value={row.unit ?? ""}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
unit: event.target.value || undefined,
}
: other,
),
)
}
/>
</div>
</div>
))}
{rows.length < MAX_ROWS ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setRows([...rows, {}])}
data-testid="add-row"
>
<Plus />
Add row
</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 === "chart" ? (
<div className="grid gap-1.5">
<div className="flex items-center justify-between gap-2 text-sm">
Smoothing
<Switch
checked={Boolean(cfg.smooth)}
aria-label="Smoothing"
data-testid="chart-smooth"
onCheckedChange={(smooth) => set({ smooth })}
/>
</div>
<p className="text-xs text-muted-foreground">
Curves the lines between readings. It draws them softer; it does
not change what was measured.
</p>
</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>
)
}
/**
* The dashboard's colours, pasted or typed.
*
* A link is the fastest way to a palette somebody already likes, so anything
* with hex in it is read — a coolors.co link, a colorhunt one, a comma list, a
* column of `#rrggbb`. What is kept is the order, because order is the role.
*
* The text is held locally so a half-typed link is not fought over while it is
* being typed; the parsed colours are written through on every keystroke, which
* is what makes pasting a link show the dashboard immediately.
*/
function PalettePicker({
palette,
onChange,
}: {
palette: string[]
onChange: (palette: string[]) => void
}) {
const [draft, setDraft] = useState(palette.join(" "))
const write = (text: string) => {
setDraft(text)
const next = parsePalette(text)
if (next.join(" ") !== palette.join(" ")) onChange(next)
}
return (
<div className="grid gap-2">
<Input
placeholder="Paste a coolors.co link, or hex colours"
aria-label="Palette"
data-testid="dashboard-palette-input"
value={draft}
onChange={(event) => write(event.target.value)}
/>
{palette.length === 0 ? (
<p className="text-sm text-muted-foreground">
No palette this dashboard keeps the app's own colours.
</p>
) : (
<div
className="flex flex-wrap items-end gap-2"
data-testid="dashboard-palette"
>
{palette.map((hex, index) => (
<span
// Position is the role, so it is also the identity: the same
// colour twice is two different jobs.
key={`${hex}-${index}`}
className="grid justify-items-center gap-1"
>
<span
className="size-8 rounded-full border border-border"
style={{ background: hex }}
aria-label={`${roleLabel(index)} ${hex}`}
role="img"
/>
<span className="text-xs text-muted-foreground">
{roleLabel(index)}
</span>
</span>
))}
{/* The roles are positional, and a palette rarely arrives in the
order a dashboard wants them. Turning it is quicker than
retyping five colours. */}
<Button
variant="ghost"
size="icon"
aria-label="Rotate palette"
data-testid="dashboard-palette-rotate"
disabled={palette.length < 2}
onClick={() => {
const next = [...palette.slice(1), palette[0]]
setDraft(next.join(" "))
onChange(next)
}}
>
<RotateCw />
</Button>
</div>
)}
</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"
if (name === "touch") return value === true ? "touch friendly" : "pointer"
if (name === "look") return lookOf(value)
if (name === "background") return value ? "that image" : "no image"
if (name === "palette") {
const count = parsePalette(value).length
return count ? `those ${count} colours` : "no palette"
}
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")
const look = settingOf(dashboard, "look")
const background = settingOf(dashboard, "background")
const touch = settingOf(dashboard, "touch")
const paletteSetting = settingOf(dashboard, "palette")
const palette = parsePalette(paletteSetting.value)
/** 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}>Look</span>
<Segmented
value={lookOf(look.value)}
options={LOOK_CHOICES}
label="Dashboard look"
testId="dashboard-look"
onChange={(value) => setSetting("look", { ...look, value })}
/>
<SettingBinding
name="look"
setting={look}
onChange={(setting) => setSetting("look", setting)}
/>
<p className="text-sm text-muted-foreground">
How this dashboard is drawn. Fluksio is the app's own design, and
what a dashboard wears until you choose otherwise; Material lays
flat tonal cards on a plain ground; Glass floats translucent tiles
over a soft moving one. The widgets are the same whichever you
pick — a look changes what they look like and nothing about what
they do.
</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. A palette settles this for
itself: its first colour is the ground, so a dashboard that names
one is already light or dark and this is left idle.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Palette</span>
<PalettePicker
palette={palette}
onChange={(value: string[]) =>
setSetting("palette", { ...paletteSetting, value })
}
/>
<SettingBinding
name="palette"
setting={paletteSetting}
onChange={(setting) => setSetting("palette", setting)}
/>
<p className="text-sm text-muted-foreground">
The colours this dashboard is drawn in, in order: the ground, the
surface a widget is, the primary, the accent, and the text. Leave
the later ones off and they are worked out from the ones you gave
— three colours are a whole dashboard. Anything past the five is
another colour for a chart to draw a line in. Name none and the
dashboard keeps the app's own.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Background</span>
<Input
type="url"
placeholder="https://…"
aria-label="Background image"
data-testid="dashboard-background"
value={str(background.value)}
onChange={(event) =>
setSetting("background", {
...background,
value: event.target.value,
})
}
/>
<SettingBinding
name="background"
setting={background}
onChange={(setting) => setSetting("background", setting)}
/>
<p className="text-sm text-muted-foreground">
An image drawn under the widgets, covering the canvas. It takes
the place of the ground the Glass look brings with it. Bind a
message and a flow decides the picture one per season, or one
per time of day.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Touch</span>
<div className="flex items-center justify-between gap-2 text-sm">
Touch friendly
<Switch
checked={touch.value === true}
aria-label="Touch friendly"
data-testid="dashboard-touch"
onCheckedChange={(value) =>
setSetting("touch", { ...touch, value })
}
/>
</div>
<SettingBinding
name="touch"
setting={touch}
onChange={(setting) => setSetting("touch", setting)}
/>
<p className="text-sm text-muted-foreground">
Bigger controls, and nothing that only happens on hover for a
panel that is touched rather than pointed at. A phone gets this
anyway; a wall panel has no way to say so for itself.
</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>
</>
)
}