A bar drew its nested reading on top of the outer one in --chart-5, which measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The readings now partition the fill end to end, up to three of them, in a token of their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment is drawn inside a gutter of outer fill rather than ever bordering the track, which is what separates neighbours too, and what caps the count at three. A nested value larger than its outer used to spill onto the track; it is clamped. `inner` still reads as a single binding, so no dashboard needs migrating. On a phone, .widget-grid took its width from the widest thing any widget held — a truncating flex item still offers its whole unwrapped line as a min-content contribution — and a handful of widgets had no floor of their own: the uPlot legend is a table, a fieldset carries min-inline-size: min-content from the UA sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body scrolls rather than clipping, so long text stops painting over the title. Gauges and bars move between readings instead of jumping, and a segmented control slides one thumb rather than recolouring cells. The gauge arc is drawn whole and revealed by its dash, because `d` cannot be transitioned. UplotChart pushed new readings only when the point count changed, so once a rolling window was full a refetch left the old values on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
1057 lines
35 KiB
TypeScript
1057 lines
35 KiB
TypeScript
import { useQuery } from "@tanstack/react-query"
|
||
import { Plus, X } from "lucide-react"
|
||
import { useState } from "react"
|
||
|
||
import type { MessageInfo, WidgetDef } from "@/client"
|
||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||
import {
|
||
PANEL_SECTION,
|
||
PanelTitle,
|
||
SidePanel,
|
||
} from "@/components/Flow/SidePanel"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { cn } from "@/lib/utils"
|
||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||
import {
|
||
CANVAS_PRESETS,
|
||
COLUMN_CHOICES,
|
||
canvasOf,
|
||
columnsOf,
|
||
type Dashboard,
|
||
} from "./DashboardView"
|
||
import { ICON_COLORS, ICON_NAMES } from "./icons"
|
||
import { messageCatalogQueryOptions } from "./queries"
|
||
import {
|
||
acceptsDtype,
|
||
INPUT_WIDGETS,
|
||
type Series,
|
||
seriesOf,
|
||
WIDGET_LABELS,
|
||
type WidgetKind,
|
||
widgetIssue,
|
||
} from "./widgets"
|
||
|
||
const config = (widget: WidgetDef) =>
|
||
(widget.config ?? {}) as Record<string, unknown>
|
||
|
||
const str = (value: unknown) => (value == null ? "" : String(value))
|
||
|
||
/** Which messages this kind of widget may be pointed at. */
|
||
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
|
||
const input = INPUT_WIDGETS.has(kind)
|
||
return catalog.filter(
|
||
(message) =>
|
||
acceptsDtype(kind, message.dtype) &&
|
||
// Flows own the namespace; a control can only set what one declares.
|
||
(!input || message.writable !== false),
|
||
)
|
||
}
|
||
|
||
/** The picker, which records the payload type it bound along with the name. */
|
||
function MessagePicker({
|
||
kind,
|
||
value,
|
||
label,
|
||
testId,
|
||
filter,
|
||
onPick,
|
||
}: {
|
||
kind: WidgetKind
|
||
value: string
|
||
label: string
|
||
testId?: string
|
||
/** What this slot takes, when the widget's own type does not decide it —
|
||
* a querying chart asks with one shape and draws another. */
|
||
filter?: (message: MessageInfo) => boolean
|
||
onPick: (message: string, dtype: string) => void
|
||
}) {
|
||
const { data } = useQuery(messageCatalogQueryOptions())
|
||
const catalog = data?.data ?? []
|
||
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog)
|
||
|
||
return (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">{label}</Label>
|
||
<Select
|
||
value={value}
|
||
onValueChange={(next) =>
|
||
onPick(
|
||
next,
|
||
choices.find((message) => message.name === next)?.dtype ?? "",
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger data-testid={testId}>
|
||
<SelectValue placeholder="Pick a message" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{choices.map((message) => (
|
||
<SelectItem key={message.name} value={message.name}>
|
||
{message.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Where a chart's lines come from: what the engine kept, or what it asks for.
|
||
*
|
||
* The one segmented shape — a single border pill, transparent segments,
|
||
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
|
||
*/
|
||
function ModePicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: "live" | "query"
|
||
onChange: (mode: "live" | "query") => void
|
||
}) {
|
||
return (
|
||
<fieldset
|
||
data-testid="chart-source"
|
||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||
>
|
||
<legend className="sr-only">Where the chart's data comes from</legend>
|
||
{(
|
||
[
|
||
["live", "Live"],
|
||
["query", "Query"],
|
||
] as const
|
||
).map(([mode, label]) => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
aria-pressed={value === mode}
|
||
onClick={() => onChange(mode)}
|
||
className={cn(
|
||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||
value === mode
|
||
? "bg-accent text-accent-foreground"
|
||
: "text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</fieldset>
|
||
)
|
||
}
|
||
|
||
/** Which chrome an input wears, in the same segmented shape as the mode. */
|
||
function StylePicker({
|
||
value,
|
||
options,
|
||
onChange,
|
||
}: {
|
||
value: string
|
||
options: readonly (readonly [string, string])[]
|
||
onChange: (style: string) => void
|
||
}) {
|
||
return (
|
||
<fieldset
|
||
data-testid="widget-style"
|
||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||
>
|
||
<legend className="sr-only">How this control is drawn</legend>
|
||
{options.map(([style, label]) => (
|
||
<button
|
||
key={style}
|
||
type="button"
|
||
aria-pressed={value === style}
|
||
onClick={() => onChange(style)}
|
||
className={cn(
|
||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||
value === style
|
||
? "bg-accent text-accent-foreground"
|
||
: "text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</fieldset>
|
||
)
|
||
}
|
||
|
||
/** What a text field was meant to send: a bool, a number, or the text itself. */
|
||
function coerce(raw: string): unknown {
|
||
const asNumber = Number(raw)
|
||
if (raw === "true" || raw === "false") return raw === "true"
|
||
return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw
|
||
}
|
||
|
||
/**
|
||
* What one widget shows or does.
|
||
*
|
||
* The same floating panel the flow editor uses for a node, so the two editors
|
||
* read as one surface.
|
||
*/
|
||
export function WidgetPanel({
|
||
widget,
|
||
onChange,
|
||
onDelete,
|
||
onClose,
|
||
}: {
|
||
widget: WidgetDef | null
|
||
onChange: (changes: Partial<WidgetDef>) => void
|
||
onDelete: () => void
|
||
onClose: () => void
|
||
}) {
|
||
if (!widget) return null
|
||
const cfg = config(widget)
|
||
const isInput = INPUT_WIDGETS.has(widget.type)
|
||
const issue = widgetIssue(widget)
|
||
|
||
const set = (changes: Record<string, unknown>) =>
|
||
onChange({ config: { ...cfg, ...changes } })
|
||
|
||
const series = seriesOf(widget)
|
||
const setSeries = (next: Series[]) => set({ series: next })
|
||
// A bar's nested readings. An empty row stands in for none, so an unnested
|
||
// bar still offers the picker rather than only a button.
|
||
const segments = segmentsOf(widget)
|
||
const rows: Segment[] = segments.length ? segments : [{}]
|
||
// Always written as a list; `inner_dtype` belonged to the single binding a
|
||
// bar carried before it stacked, and goes with it.
|
||
const setSegments = (next: Segment[]) =>
|
||
set({ inner: next, inner_dtype: undefined })
|
||
// The icon widget's mapping. Position is the row's identity, as with series.
|
||
const rules = (cfg.rules ?? []) as {
|
||
at?: unknown
|
||
icon?: string
|
||
color?: string
|
||
label?: string
|
||
}[]
|
||
const setRules = (next: typeof rules) => set({ rules: next })
|
||
// Nothing wrote these until now, so a dropdown's choices were uneditable.
|
||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||
const setOptions = (next: typeof options) => set({ options: next })
|
||
const querying = cfg.source === "query"
|
||
// What this chart would refresh at with nothing configured. The viewer can
|
||
// pick another window on the widget, which moves it.
|
||
const paced = refreshFor(
|
||
RANGES.find((range) => range.hours * 3600 === Number(cfg.range_s)) ??
|
||
DEFAULT_RANGE,
|
||
0,
|
||
)
|
||
|
||
return (
|
||
<SidePanel
|
||
open={Boolean(widget)}
|
||
label="Widget settings"
|
||
testId="widget-panel"
|
||
bodyKey={widget.id}
|
||
onClose={onClose}
|
||
header={
|
||
<PanelTitle
|
||
value={widget.title ?? ""}
|
||
placeholder={WIDGET_LABELS[widget.type]}
|
||
label="Widget title"
|
||
onConfirm={(title) => onChange({ title })}
|
||
/>
|
||
}
|
||
footer={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-destructive hover:text-destructive"
|
||
onClick={onDelete}
|
||
data-testid="delete-widget"
|
||
>
|
||
Remove widget
|
||
</Button>
|
||
}
|
||
>
|
||
<div className="grid gap-5 p-4" data-testid="widget-settings">
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>{WIDGET_LABELS[widget.type]}</span>
|
||
{issue ? (
|
||
<p className="text-sm text-destructive" data-testid="widget-error">
|
||
{issue}
|
||
</p>
|
||
) : null}
|
||
|
||
{widget.type === "markdown" ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Text</Label>
|
||
<Input
|
||
value={str(cfg.content)}
|
||
placeholder="# Heading"
|
||
onChange={(event) => set({ content: event.target.value })}
|
||
/>
|
||
</div>
|
||
) : widget.type === "chart" ? (
|
||
<div className="grid gap-3">
|
||
<ModePicker
|
||
value={querying ? "query" : "live"}
|
||
onChange={(source) => set({ source })}
|
||
/>
|
||
{querying ? (
|
||
<div className="grid gap-2">
|
||
<MessagePicker
|
||
kind="chart"
|
||
value={str(cfg.request)}
|
||
label="Asks"
|
||
testId="widget-request"
|
||
filter={(message) =>
|
||
message.dtype === "record" && message.writable !== false
|
||
}
|
||
onPick={(request, request_dtype) =>
|
||
set({ request, request_dtype })
|
||
}
|
||
/>
|
||
<MessagePicker
|
||
kind="chart"
|
||
value={str(cfg.message)}
|
||
label="Draws"
|
||
testId="widget-message"
|
||
filter={(message) => message.dtype === "series"}
|
||
onPick={(message, dtype) => set({ message, dtype })}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-2">
|
||
{series.map((entry, index) => (
|
||
<div
|
||
// Position is the only identity a series row has.
|
||
key={`series-${index}`}
|
||
className="flex items-end gap-1.5"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<MessagePicker
|
||
kind="chart"
|
||
value={entry.message ?? ""}
|
||
label={index === 0 ? "Draws" : ""}
|
||
testId={index === 0 ? "widget-message" : undefined}
|
||
onPick={(message, dtype) =>
|
||
setSeries(
|
||
series.map((other, at) =>
|
||
at === index
|
||
? { ...other, message, dtype }
|
||
: other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<Input
|
||
className="w-28"
|
||
value={entry.label ?? ""}
|
||
placeholder="Label"
|
||
aria-label="Series label"
|
||
onChange={(event) =>
|
||
setSeries(
|
||
series.map((other, at) =>
|
||
at === index
|
||
? { ...other, label: event.target.value }
|
||
: other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
aria-label="Remove series"
|
||
onClick={() =>
|
||
setSeries(series.filter((_, at) => at !== index))
|
||
}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{series.length < MAX_SERIES ? (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 justify-self-start"
|
||
onClick={() => setSeries([...series, {}])}
|
||
data-testid="add-series"
|
||
>
|
||
<Plus />
|
||
Add series
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : // A clock reads the wall; a picker would bind a message nothing reads.
|
||
widget.type === "clock" ? null : (
|
||
<MessagePicker
|
||
kind={widget.type}
|
||
value={str(cfg[isInput ? "target" : "message"])}
|
||
label={isInput ? "Publishes to" : "Shows"}
|
||
testId="widget-message"
|
||
onPick={(message, dtype) =>
|
||
set({ [isInput ? "target" : "message"]: message, dtype })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{widget.type === "bar" ? (
|
||
<div className="grid gap-2">
|
||
{rows.map((segment, index) => (
|
||
<div
|
||
// Position is the only identity a segment row has, as with series.
|
||
key={`segment-${index}`}
|
||
className="flex items-end gap-1.5"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<MessagePicker
|
||
kind="bar"
|
||
value={segment.message ?? ""}
|
||
label={index === 0 ? "Nested bar" : ""}
|
||
testId={index === 0 ? "widget-inner" : undefined}
|
||
onPick={(message, dtype) =>
|
||
setSegments(
|
||
rows.map((other, at) =>
|
||
at === index ? { ...other, message, dtype } : other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
aria-label="Remove segment"
|
||
onClick={() =>
|
||
setSegments(rows.filter((_, at) => at !== index))
|
||
}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{rows.length < MAX_SEGMENTS ? (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 justify-self-start"
|
||
onClick={() => setSegments([...rows, {}])}
|
||
data-testid="add-segment"
|
||
>
|
||
<Plus />
|
||
Add segment
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "chart" && !querying ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Points kept</Label>
|
||
<Input
|
||
type="number"
|
||
value={str(
|
||
(cfg.history as { points?: number } | undefined)?.points ?? 300,
|
||
)}
|
||
onChange={(event) =>
|
||
set({ history: { points: Number(event.target.value) || 0 } })
|
||
}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
How much past the engine keeps for these messages.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "chart" && querying ? (
|
||
<>
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Refresh, seconds</Label>
|
||
<Input
|
||
type="number"
|
||
min={paced}
|
||
placeholder={str(paced)}
|
||
value={str(cfg.refresh_s ?? "")}
|
||
onChange={(event) =>
|
||
set({
|
||
refresh_s:
|
||
event.target.value === ""
|
||
? undefined
|
||
: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Empty follows the window — {paced} seconds at this range, since
|
||
nothing changes until the bucket closes. A slower one is kept, a
|
||
faster one only asks for the same picture twice.
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Range shown first</Label>
|
||
<Select
|
||
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
|
||
onValueChange={(value) => set({ range_s: Number(value) })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{RANGES.map((range) => (
|
||
<SelectItem
|
||
key={range.label}
|
||
value={String(range.hours * 3600)}
|
||
>
|
||
{range.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-muted-foreground">
|
||
A viewer can pick another on the widget itself.
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{widget.type === "agenda" || widget.type === "forecast" ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Items shown</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
value={str(cfg.count ?? 5)}
|
||
onChange={(event) =>
|
||
set({ count: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "chart" ? (
|
||
<div className="flex gap-2">
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Y minimum</Label>
|
||
<Input
|
||
type="number"
|
||
value={str(cfg.y_min ?? "")}
|
||
placeholder="auto"
|
||
onChange={(event) =>
|
||
set({
|
||
y_min:
|
||
event.target.value === ""
|
||
? undefined
|
||
: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Y maximum</Label>
|
||
<Input
|
||
type="number"
|
||
value={str(cfg.y_max ?? "")}
|
||
placeholder="auto"
|
||
onChange={(event) =>
|
||
set({
|
||
y_max:
|
||
event.target.value === ""
|
||
? undefined
|
||
: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Y axis title</Label>
|
||
<Input
|
||
value={str(cfg.y_label)}
|
||
placeholder="kW"
|
||
onChange={(event) => set({ y_label: event.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "stat" ||
|
||
widget.type === "gauge" ||
|
||
widget.type === "chart" ||
|
||
widget.type === "bar" ||
|
||
widget.type === "slider" ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Unit</Label>
|
||
<Input
|
||
value={str(cfg.unit)}
|
||
placeholder="°C"
|
||
onChange={(event) => set({ unit: event.target.value })}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "gauge" ||
|
||
widget.type === "slider" ||
|
||
widget.type === "bar" ? (
|
||
<div className="flex gap-2">
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Minimum</Label>
|
||
<Input
|
||
type="number"
|
||
value={str(cfg.min ?? 0)}
|
||
onChange={(event) =>
|
||
set({ min: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Maximum</Label>
|
||
<Input
|
||
type="number"
|
||
value={str(cfg.max ?? 100)}
|
||
onChange={(event) =>
|
||
set({ max: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
</div>
|
||
{widget.type === "slider" ? (
|
||
<div className="grid flex-1 gap-1.5">
|
||
<Label className="text-sm font-normal">Step</Label>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step="any"
|
||
value={str(cfg.step ?? 1)}
|
||
onChange={(event) =>
|
||
set({ step: Number(event.target.value) || 1 })
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "button" ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Sends</Label>
|
||
<Input
|
||
value={str(cfg.value)}
|
||
placeholder="true"
|
||
onChange={(event) => set({ value: coerce(event.target.value) })}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "dropdown" ? (
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>Options</span>
|
||
{options.map((option, index) => (
|
||
<div
|
||
// Position is the only identity an option row has.
|
||
key={`option-${index}`}
|
||
className="flex items-end gap-1.5"
|
||
>
|
||
<Input
|
||
className="min-w-0 flex-1"
|
||
value={str(option.label)}
|
||
placeholder="Label"
|
||
aria-label="Option label"
|
||
onChange={(event) =>
|
||
setOptions(
|
||
options.map((other, at) =>
|
||
at === index
|
||
? { ...other, label: event.target.value }
|
||
: other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Input
|
||
className="w-28"
|
||
value={str(option.value)}
|
||
placeholder="Value"
|
||
aria-label="Option value"
|
||
onChange={(event) =>
|
||
setOptions(
|
||
options.map((other, at) =>
|
||
at === index
|
||
? { ...other, value: coerce(event.target.value) }
|
||
: other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
aria-label="Remove option"
|
||
onClick={() =>
|
||
setOptions(options.filter((_, at) => at !== index))
|
||
}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 justify-self-start"
|
||
onClick={() => setOptions([...options, {}])}
|
||
data-testid="add-option"
|
||
>
|
||
<Plus />
|
||
Add option
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "icon" ? (
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>Mapping</span>
|
||
{rules.map((rule, index) => (
|
||
<div
|
||
// Position is the only identity a rule row has.
|
||
key={`rule-${index}`}
|
||
className="flex items-end gap-1.5"
|
||
>
|
||
<Input
|
||
className="w-20"
|
||
value={str(rule.at)}
|
||
placeholder="Value"
|
||
aria-label="Rule value"
|
||
onChange={(event) =>
|
||
setRules(
|
||
rules.map((other, at) =>
|
||
at === index
|
||
? { ...other, at: coerce(event.target.value) }
|
||
: other,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<Select
|
||
value={rule.icon ?? ""}
|
||
onValueChange={(icon) =>
|
||
setRules(
|
||
rules.map((other, at) =>
|
||
at === index ? { ...other, icon } : other,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
className="min-w-0 flex-1"
|
||
aria-label="Rule icon"
|
||
>
|
||
<SelectValue placeholder="Icon" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ICON_NAMES.map((name) => (
|
||
<SelectItem key={name} value={name}>
|
||
{name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={rule.color ?? "default"}
|
||
onValueChange={(color) =>
|
||
setRules(
|
||
rules.map((other, at) =>
|
||
at === index ? { ...other, color } : other,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger className="w-28" aria-label="Rule colour">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Object.keys(ICON_COLORS).map((name) => (
|
||
<SelectItem key={name} value={name}>
|
||
{name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
aria-label="Remove rule"
|
||
onClick={() =>
|
||
setRules(rules.filter((_, at) => at !== index))
|
||
}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 justify-self-start"
|
||
onClick={() => setRules([...rules, {}])}
|
||
data-testid="add-icon-rule"
|
||
>
|
||
<Plus />
|
||
Add value
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">
|
||
Rows are checked top to bottom and the first match wins. A number
|
||
also matches anything above it, so a descending ladder reads as
|
||
thresholds.
|
||
</p>
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Otherwise</Label>
|
||
<Select
|
||
value={str(cfg.icon)}
|
||
onValueChange={(icon) => set({ icon })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Nothing" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ICON_NAMES.map((name) => (
|
||
<SelectItem key={name} value={name}>
|
||
{name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{widget.type === "switch" || widget.type === "dropdown" ? (
|
||
<div className="grid gap-1.5">
|
||
<Label className="text-sm font-normal">Style</Label>
|
||
{widget.type === "switch" ? (
|
||
<StylePicker
|
||
value={str(cfg.style) || "track"}
|
||
options={[
|
||
["track", "Track"],
|
||
["button", "Button"],
|
||
]}
|
||
onChange={(style) => set({ style })}
|
||
/>
|
||
) : (
|
||
<StylePicker
|
||
value={str(cfg.style) || "list"}
|
||
options={[
|
||
["list", "List"],
|
||
["segmented", "Segmented"],
|
||
]}
|
||
onChange={(style) => set({ style })}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SidePanel>
|
||
)
|
||
}
|
||
|
||
/** Presets are matched on their size, so a custom one simply matches none. */
|
||
const sizeKey = (size: { width: number; height: number }) =>
|
||
`${size.width}x${size.height}`
|
||
|
||
/**
|
||
* The dashboard's own settings, in the panel its widgets use.
|
||
*
|
||
* The canvas is the one that matters: a wall panel is a fixed size, and twelve
|
||
* columns on a seven-inch screen is a different dashboard than twelve on a
|
||
* television.
|
||
*/
|
||
export function DashboardPanel({
|
||
open,
|
||
dashboard,
|
||
widgetCount,
|
||
onChange,
|
||
onDelete,
|
||
onClose,
|
||
}: {
|
||
open: boolean
|
||
dashboard: Dashboard
|
||
widgetCount: number
|
||
onChange: (changes: Partial<Dashboard>) => void
|
||
onDelete: () => void
|
||
onClose: () => void
|
||
}) {
|
||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||
const canvas = canvasOf(dashboard)
|
||
|
||
return (
|
||
<>
|
||
<SidePanel
|
||
open={open}
|
||
label="Dashboard settings"
|
||
testId="dashboard-panel"
|
||
bodyKey={dashboard.name}
|
||
onClose={onClose}
|
||
header={
|
||
<PanelTitle
|
||
value={dashboard.title ?? ""}
|
||
placeholder={dashboard.name}
|
||
label="Dashboard title"
|
||
onConfirm={(title) => onChange({ title })}
|
||
/>
|
||
}
|
||
footer={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-destructive hover:text-destructive"
|
||
onClick={() => setConfirmOpen(true)}
|
||
data-testid="delete-dashboard"
|
||
>
|
||
Delete dashboard
|
||
</Button>
|
||
}
|
||
>
|
||
<div className="grid gap-5 p-4">
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>Grid</span>
|
||
<Select
|
||
value={String(columnsOf(dashboard))}
|
||
onValueChange={(value) => onChange({ columns: Number(value) })}
|
||
>
|
||
<SelectTrigger data-testid="dashboard-columns">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{COLUMN_CHOICES.map((count) => (
|
||
<SelectItem key={count} value={String(count)}>
|
||
{count} columns
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-sm text-muted-foreground">
|
||
How many columns wide this dashboard is laid out, so it can be
|
||
matched to the panel it will hang on.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>Canvas</span>
|
||
<Select
|
||
value={
|
||
CANVAS_PRESETS.some(
|
||
(preset) => sizeKey(preset) === sizeKey(canvas),
|
||
)
|
||
? sizeKey(canvas)
|
||
: ""
|
||
}
|
||
onValueChange={(value) => {
|
||
const preset = CANVAS_PRESETS.find(
|
||
(candidate) => sizeKey(candidate) === value,
|
||
)
|
||
if (preset)
|
||
onChange({
|
||
canvas_width: preset.width,
|
||
canvas_height: preset.height,
|
||
})
|
||
}}
|
||
>
|
||
<SelectTrigger data-testid="dashboard-canvas-size">
|
||
<SelectValue placeholder="Custom" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{CANVAS_PRESETS.map((preset) => (
|
||
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
|
||
{preset.label} — {preset.width}×{preset.height}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="number"
|
||
aria-label="Canvas width"
|
||
data-testid="canvas-width"
|
||
value={canvas.width}
|
||
onChange={(event) =>
|
||
onChange({ canvas_width: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
<Input
|
||
type="number"
|
||
aria-label="Canvas height"
|
||
data-testid="canvas-height"
|
||
value={canvas.height}
|
||
onChange={(event) =>
|
||
onChange({ canvas_height: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
The panel this is drawn for, in pixels. Editing and viewing both
|
||
scale that surface to fit, so the arrangement is the same
|
||
everywhere.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<span className={PANEL_SECTION}>Contents</span>
|
||
<p className="text-sm text-muted-foreground">
|
||
<span className="font-mono">{dashboard.name}</span> —{" "}
|
||
{widgetCount === 0
|
||
? "nothing on it yet."
|
||
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SidePanel>
|
||
|
||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
Delete {dashboard.title || dashboard.name}?
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
This removes the dashboard and its widgets. The flows it read from
|
||
are untouched, and its history stays in the flow store's git
|
||
repository.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||
Keep it
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => {
|
||
setConfirmOpen(false)
|
||
onDelete()
|
||
}}
|
||
data-testid="confirm-delete-dashboard"
|
||
>
|
||
Delete dashboard
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|