Shrinking a dashboard's canvas silently clipped whatever now fell past the bottom edge: `maxRows` constrains a drag and nothing else, so a stored placement is corrected against the column count alone. Nothing warned, and nothing offered a way out. The remedy is a notice rather than a reflow, because the canvas height is written on every keystroke — typing 400 passes through 4 and 40, and anything that moved widgets would flatten the arrangement while the number was still being typed. The notice carries the reflow as its one button, and packing is sideways because the grid already compacts vertically: nothing below the canvas has room above it. Dropping a widget also selected it, which opened its panel, which rescaled the canvas under the pointer. The drag handle was simply missing from the selector that already exempts the resize handle — which is why resizing never had this problem. Also: an icon rule's caption could only be set through the API, and the panel rail drew two letters where a dashboard can now carry a lucide icon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
1094 lines
36 KiB
TypeScript
1094 lines
36 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 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,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<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>
|
||
<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>
|
||
<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}>Rail icon</span>
|
||
<Select
|
||
value={str(dashboard.icon)}
|
||
onValueChange={(icon) => onChange({ icon })}
|
||
>
|
||
<SelectTrigger data-testid="dashboard-icon">
|
||
<SelectValue placeholder="Two letters of the title" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ICON_NAMES.map((name) => (
|
||
<SelectItem key={name} value={name}>
|
||
{name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<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}>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>
|
||
</>
|
||
)
|
||
}
|