Widget panel: settings for the bar, icon, forecast and chart axis title
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

The four widget types added this round had renderers but no way to configure
them. The panel now offers a bar's nested reading, unit and range (no step —
that is a slider's), the forecast's item count on the agenda's field, a chart's
y axis title beside its y range, and the icon's mapping editor: a value, a
glyph and a colour per row, first match wins, with a fallback glyph below.

Adds tests/widgets.spec.ts, which asserts each of them on /view: the nested bar
inside its outer fill, the glyph following the message, five forecast columns
fading outwards, a clock that reads the wall without being flagged unbound,
a segmented control and a latching button reading back what they published,
and an unbound tile that says so instead of taking the page down. The axis
title is drawn into uPlot's canvas, so it is checked by panel round-trip.
mobile.spec.ts grows a bar and a forecast so the width check covers them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 09:48:42 +02:00
co-authored by Claude Opus 5
parent a00b4eaec2
commit 85098eb0dc
4 changed files with 549 additions and 25 deletions
+150 -2
View File
@@ -36,6 +36,7 @@ import {
columnsOf,
type Dashboard,
} from "./DashboardView"
import { ICON_COLORS, ICON_NAMES } from "./icons"
import { messageCatalogQueryOptions } from "./queries"
import {
acceptsDtype,
@@ -226,6 +227,14 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
// 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 })
@@ -394,6 +403,16 @@ export function WidgetPanel({
)}
</div>
{widget.type === "bar" ? (
<MessagePicker
kind="bar"
value={str(cfg.inner)}
label="Nested bar"
testId="widget-inner"
onPick={(inner, inner_dtype) => set({ inner, inner_dtype })}
/>
) : null}
{widget.type === "chart" && !querying ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Points kept</Label>
@@ -463,7 +482,7 @@ export function WidgetPanel({
</>
) : null}
{widget.type === "agenda" ? (
{widget.type === "agenda" || widget.type === "forecast" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Items shown</Label>
<Input
@@ -511,12 +530,21 @@ export function WidgetPanel({
}
/>
</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>
@@ -528,7 +556,9 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "gauge" || widget.type === "slider" ? (
{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>
@@ -643,6 +673,124 @@ export function WidgetPanel({
</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>