1762 lines
59 KiB
TypeScript
1762 lines
59 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|