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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user