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
+12 -23
View File
@@ -15,30 +15,33 @@ should reopen it.
- BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it
- BUG/UX the console/log panel does not show print output of nodes
- BUG/UI loop-lag in home view can't have a value when there are no flows (push back if I'm wrong here)
- BUG/UI brain view in the home view should be hidden when there are no flows/nodes
- CHORE/UI: loop lag on Home reads a real number with no flows, and that is right — `LoopWatchdog` times how late `asyncio.sleep(1.0)` wakes on the API's event loop and is started unconditionally, so it measures the engine process rather than any flow, and it is what turns the health badge `degraded`. Nothing to fix; recorded so it is not reopened.
- FEAT/UI in addition to the two issues above we should introduce a default flow and dashboard for every new user. These should be very minimal to quickly understand what's happening but still show off most of the features of the application. We could make this optional by offering a "Create Demo" button showing at the location of the brain view if there are no existing flows. This demo could then also serve as a demo.fluksio.com site later to play around without signup
- BUG/UI auto node placement on flows should be improved in regards to least crossing edges and a more vertical layout on mobile devices
- BUG/UI when opening a flow, the graph should enlarge from the center (same animation as the brain view). Currently it swipes in from the side
- BUG/UI dots and rings of nodes in the brain graph are not pixel-perfect centered
- BUG/UI rings of ndoes in the brain graph should be come a bit thinner and edges a bit thicker. Also ensure that trigger animations of nodes are still visible. We could use make the ring pulse instead of the existing effect
- BUG/UI HEALTH section shows 1 flow(s) cannot run but boxes below show e.g. 5/5 flows running
- FEAT/UI we should highlight failing nodes accordingly in the flow view to facilitate easier tracking of mis-configurations
- BUG/UI when a dashboard widget is selected, the border does not cleanly draw on the left side of the widget (like it is obscured by the widget)
- BUG/UI: `FlowEditor` wires `onNodesChange` but no `onEdgesChange`, so React Flow's selection change never reaches the edge state and no edge in the flow editor ever carries `.selected`. Both `.react-flow__edge.selected { --edge-rest: var(--primary) }` and `LiveEdge`'s selected stroke are therefore unreachable; clicking an edge only opens the `EdgeInspector`.
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
- CHORE/INFRA: `bunx playwright test` from the host is unsafe against this stack — `app.fluksio.com` and `api.fluksio.com` resolve to production, and `--host-resolver-rules` steers only Chromium while `page.request.*` resolves through Node, so setup and teardown write to the live instance. Run the specs in a container on the `proxy` network with `--add-host` pointing both names at Traefik.
- CHORE/INFRA: `make test-backend` reads `app/.env`, so a checkout configured for a deployment fails two tests that assume the development defaults — `test_private.py::test_create_user` (the `/private` router is off unless `ENVIRONMENT=local`) and `test_mcp_http.py::test_an_agent_can_list_and_call_tools` (the MCP host allow-list is built from `DOMAIN` while the test speaks to `api.localhost`). Both pass under `ENVIRONMENT=local DOMAIN=localhost`; the suite should pin its own environment rather than inherit the deployment's.
- BUG/INFRA: `tests/utils/user.ts`'s `logInUser` still waits for a `Hi, …` heading on Home, which was removed in `449a147`, so both `admin.spec.ts` cases fail on login. Pick an anchor the current Home actually renders.
- CHORE/DOCS: `app/development.md` is still the upstream template's text — `compose.override.yml`, `localhost.tiangolo.com` — and says nothing about the Playwright suite. The containerised run above is what it should carry.
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
- FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted.
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
- CHORE/UI: `layoutGraph` treats every node as 220×56 rather than measuring, because feeding a measurement back into the layout oscillates. A node wider than that crowds its neighbours; take the sizes from `node.measured` once they have settled if it shows.
- FEAT/UI/MOBILE: a rank of many nodes — a connector feeding eight dashboard tiles — is thousands of pixels wide however the graph is turned, so on a phone the fit shrinks it past reading. The layout is right and the flow is simply too big for the screen; a "one rank at a time" reading mode, or wrapping a wide rank, is what would make it legible.
- CHORE/UI: an edge's value chip sits at the bezier midpoint while the layout reserves its room at dagre's label rank. The two agree closely enough today; if chips ever pile up, take the position from the layout instead.
- CHORE/UI: the bar widget's nested fill is `--chart-5` against a `--primary` outer fill, which measures 2.53:1 in light mode — under the 3:1 guideline for non-text. The `inset-y-1` gutter supplies the edge, and every other slot of the ramp collapses in dark mode instead.
- CHORE/UI: a streaming edge's dashes sit inside the `prefers-reduced-motion: no-preference` guard, so a reduced-motion user gets no streaming indicator at all. Lifting `stroke-dasharray` alone out of the guard keeps the static signal without the motion.
- CHORE/UI: only node-to-node edges carry the streaming dash. `endpoints.ts` builds a dashboard endpoint's edges without the producing port's `stream` flag, so a training loss feeding a chart — the case the march was drawn for — is still a plain line.
- CHORE/UI: the Home flow-activity table's name cell is `max-w-0` with nothing setting a floor, so `demo_training` reads as "demo⋯" at 1440px while the row has slack to spare. The cap is what keeps the table from widening the page; it wants a minimum beside it.
- CHORE/UI: `.node-pulse`'s `inset: -3px` resolves against the padding box, so on a thick border it lands inside the outer edge and paints over the status ring rather than sitting one ring outward as its comment claims. Measured on a 9px border — the comment and the geometry disagree.
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
- FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com)
- FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots
- FEAT/UI streaming edges should get animated (moving dashs) to indicate this type of output visually
### Persistence and databases
@@ -133,21 +136,6 @@ Decisions taken up front, because most items below depend on them:
- CHORE/FLOW: one request/answer pair per InfluxDB node — the first input
carrying a `flux` key is the request and the answer leaves on the first output
port. A second query stream through one node needs a second node.
- FEAT/UI: bar/level widget — a horizontal bar with its value written on it, and
a nested bar for a quantity contained in another (PV inside inverter input).
The arc gauge is the only level display today.
- FEAT/UI: latching toggle button (pressed state read back from a bool message)
and exclusive button group (one of N, the active one read back). The current
button is fire-and-forget and the switch is a lone bool, so a set of room light
modes cannot be expressed.
- FEAT/UI: icon-by-value widget — a mapping from value or range to a lucide icon
and a colour token. Carries both the weather condition icon and derived hints
such as "indoor dewpoint above outdoor, open a window".
- FEAT/UI: forecast strip widget over a `list` message — N columns of label,
icon and value, with the later columns drawn progressively dimmer.
- FEAT/UI: clock widget — local time and date, bound to nothing.
- FEAT/UI: chart axis titles. The unit, the fixed y range and the per-series
label are in; a named axis is not.
- FEAT/UI: the slider offers `step` now, but no tick labels — the `datalist`
marks are unlabelled and drop out past fifty steps.
- FEAT/API: a kiosk credential for `/view/{name}`, so a panel is not a
@@ -172,6 +160,7 @@ as an em dash.
### Dashboard follow-ups
- BUG/UI: ensure dashboard wallpanel (read-only) links hot reload automatically on dashboard changes
- CHORE/UI: an icon rule stores a `label` — the widget draws it under the glyph and uses it as the glyph's accessible name — but the mapping editor offers no field for it, so it can only be set through the API.
- BUG/UI: shrinking the canvas silently clips whatever now falls past its bottom edge. `maxRows` only constrains a new drag, not a stored placement, so nothing warns and nothing offers to reflow.
- CHORE/UX: dropping a widget also selects it, which opens its panel — which rescales the canvas the instant you let go. Correct, but it lurches; either leave the panel closed on a drag-release or animate the scale.
- CHORE/UI: `ROW_HEIGHT` is a fixed 80px while column width follows the canvas, so a 1920-wide panel at 12 columns has 160×80 cells. If that reads too wide, the row height could derive from the canvas too.
+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>
+75
View File
@@ -12,6 +12,9 @@ import { api, apiPage, deleteAll } from "./utils/api"
const flowName = `test_mobile_${Date.now().toString(36)}`
const dashboardName = `${flowName}_panel`
/** What the bar and the forecast read. A flow of its own, so the graph the
* editor lays out above stays the shape those assertions were written for. */
const feedName = `${flowName}_feed`
test.describe.configure({ mode: "serial" })
@@ -74,6 +77,54 @@ test.beforeAll(async ({ browser }) => {
data: { version: saved.definition.version },
})
await api(page, `/flows/${feedName}`, {
method: "PUT",
data: {
name: feedName,
title: "Feed",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [
{ name: "level", dtype: "float" },
{ name: "pv", dtype: "float" },
{ name: "days", dtype: "list", item: "record" },
],
},
],
},
})
const feed = await (await api(page, `/flows/${feedName}?draft=true`)).json()
await api(page, `/flows/${feedName}/publish`, {
method: "POST",
data: { version: feed.definition.version },
})
// A bar needs a reading to draw and a forecast needs its days, or neither
// widget is on the page the width check is run against.
await api(page, `/messages/${feedName}.level`, {
method: "POST",
data: { value: 62 },
})
await api(page, `/messages/${feedName}.pv`, {
method: "POST",
data: { value: 24 },
})
await api(page, `/messages/${feedName}.days`, {
method: "POST",
data: {
value: [
{ label: "Mon", icon: "sun", value: "21°" },
{ label: "Tue", icon: "cloudy", value: "18°" },
{ label: "Wed", icon: "cloud-rain", value: "15°" },
{ label: "Thu", icon: "wind", value: "16°" },
{ label: "Fri", icon: "snowflake", value: "2°" },
],
},
})
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`)
@@ -93,6 +144,29 @@ test.beforeAll(async ({ browser }) => {
layout: { lg: { x: 3, y: 0, w: 3, h: 2 } },
config: { message: `${flowName}.scaled` },
},
{
id: "level",
type: "bar",
title: "Level",
layout: { lg: { x: 0, y: 2, w: 4, h: 2 } },
config: {
message: `${feedName}.level`,
dtype: "float",
inner: `${feedName}.pv`,
inner_dtype: "float",
min: 0,
max: 100,
unit: " kW",
},
},
{
// A strip of columns is the widget most likely to widen the page.
id: "week",
type: "forecast",
title: "Week",
layout: { lg: { x: 0, y: 4, w: 6, h: 2 } },
config: { message: `${feedName}.days`, dtype: "list", count: 5 },
},
]
const draft = await (
await api(page, `/dashboards/${dashboardName}`, {
@@ -111,6 +185,7 @@ test.afterAll(async ({ browser }) => {
await deleteAll(browser, [
`/dashboards/${dashboardName}`,
`/flows/${flowName}`,
`/flows/${feedName}`,
])
})
+312
View File
@@ -0,0 +1,312 @@
import { expect, type Page, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The wall-panel widgets, on the page a panel actually opens.
*
* Each one is a picture rather than a number, so what is worth asserting is
* the picture: a nested bar sits inside its outer fill, a condition picks the
* glyph the mapping names, a forecast fades outwards, and a control that
* latches reads back what it published. The two screenshots are the other
* half — a colour that collapses in one theme is invisible to every assertion
* above.
*/
const flowName = `test_widgets_${Date.now().toString(36)}`
const dashboardName = `${flowName}_panel`
/** A message of the flow under test, qualified the way the engine names it. */
const w = (name: string) => `${flowName}.${name}`
/** Every tile the dashboard carries, including the deliberately unbound one. */
const TILES = 8
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
/** Put a value into the graph, as a flow answering would. */
const publish = (page: Page, name: string, value: unknown) =>
api(page, `/messages/${name}`, { method: "POST", data: { value } })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
// One node that only declares: nothing consumes these, so publishing a value
// fills the panel without waking an engine run.
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Widgets",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [
{ name: "level", dtype: "float" },
{ name: "pv", dtype: "float" },
{ name: "condition", dtype: "str" },
{ name: "days", dtype: "list", item: "record" },
{ name: "mode", dtype: "str" },
{ name: "lamp", dtype: "bool" },
],
},
],
},
})
// Saving writes a draft and moves the version on, so publish what is
// actually there rather than what it was a moment ago.
const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: flow.definition.version },
})
await publish(page, w("level"), 80)
await publish(page, w("pv"), 30)
await publish(page, w("condition"), "sun")
await publish(page, w("days"), [
{ label: "Mon", icon: "sun", value: "21°" },
{ label: "Tue", icon: "cloudy", value: "18°" },
{ label: "Wed", icon: "cloud-rain", value: "15°", color: "primary" },
{ label: "Thu", icon: "wind", value: "16°" },
{ label: "Fri", icon: "snowflake", value: "2°" },
// A sixth, so "Items shown" is what decides there are five columns.
{ label: "Sat", icon: "sun", value: "9°" },
])
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`)
).json()
dashboard.pages[0].sections[0].widgets = [
{
id: "load",
type: "bar",
title: "Load",
layout: { lg: { x: 0, y: 0, w: 4, h: 2 } },
config: {
message: w("level"),
dtype: "float",
inner: w("pv"),
inner_dtype: "float",
min: 0,
max: 100,
unit: " kW",
},
},
{
id: "sky",
type: "icon",
title: "Sky",
layout: { lg: { x: 4, y: 0, w: 2, h: 2 } },
config: {
message: w("condition"),
dtype: "str",
rules: [
{ at: "rain", icon: "cloud-rain", color: "primary" },
{ at: "sun", icon: "sun", color: "default" },
],
},
},
{
id: "wall",
type: "clock",
title: "Now",
layout: { lg: { x: 6, y: 0, w: 3, h: 2 } },
config: {},
},
{
id: "week",
type: "forecast",
title: "Week",
layout: { lg: { x: 0, y: 2, w: 6, h: 2 } },
config: { message: w("days"), dtype: "list", count: 5 },
},
{
id: "mode",
type: "dropdown",
title: "Mode",
layout: { lg: { x: 6, y: 2, w: 4, h: 2 } },
config: {
target: w("mode"),
dtype: "str",
style: "segmented",
options: [
{ label: "Eco", value: "eco" },
{ label: "Boost", value: "boost" },
],
},
},
{
id: "lamp",
type: "switch",
title: "Lamp",
layout: { lg: { x: 0, y: 4, w: 3, h: 2 } },
config: { target: w("lamp"), dtype: "bool", style: "button" },
},
{
// Bound to nothing on purpose: a half-configured tile has to say so
// rather than take the page down with it.
id: "spare",
type: "bar",
title: "Spare",
layout: { lg: { x: 3, y: 4, w: 3, h: 2 } },
config: {},
},
{
id: "trend",
type: "chart",
title: "Trend",
layout: { lg: { x: 6, y: 4, w: 6, h: 3 } },
config: { series: [{ message: w("level"), dtype: "float" }] },
},
]
const draft = await (
await api(page, `/dashboards/${dashboardName}`, {
method: "PUT",
data: dashboard,
})
).json()
await api(page, `/dashboards/${dashboardName}/publish`, {
method: "POST",
data: { version: draft.version },
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [
`/dashboards/${dashboardName}`,
`/flows/${flowName}`,
])
})
/** The panel as a wall panel opens it, once the tiles are drawn. */
async function openPanel(page: Page) {
await page.goto(`/view/${dashboardName}`)
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
}
test("a nested bar is drawn inside its outer fill", async ({ page }) => {
await openPanel(page)
const fill = page.getByTestId("bar-fill")
const inner = page.getByTestId("bar-inner")
await expect(inner).toBeVisible()
const outerBox = await fill.boundingBox()
const innerBox = await inner.boundingBox()
expect(outerBox).not.toBeNull()
expect(innerBox).not.toBeNull()
expect(
innerBox!.width,
"the nested share is not drawn inside the reading it is part of",
).toBeLessThan(outerBox!.width)
})
test("the icon follows what the message says", async ({ page }) => {
await openPanel(page)
const glyph = page.getByTestId("icon-glyph")
await expect(glyph).toBeVisible()
await publish(page, w("condition"), "rain")
// Colour is never the only signal: the glyph names itself.
await expect(glyph).toHaveAttribute("aria-label", /rain/i)
})
test("a forecast shows the days asked for, fading outwards", async ({
page,
}) => {
await openPanel(page)
const columns = page.getByTestId("forecast-column")
await expect(columns).toHaveCount(5)
const opacity = (index: number) =>
columns.nth(index).evaluate((el) => Number(getComputedStyle(el).opacity))
expect(
await opacity(4),
"the far end of the forecast reads as certain as the near end",
).toBeLessThan(await opacity(0))
})
test("the clock reads the wall and is not mis-wired", async ({ page }) => {
await openPanel(page)
await expect(page.getByTestId("clock-time")).toHaveText(/\d{1,2}:\d{2}/)
// Bound to nothing by design, so it must not be flagged as unbound.
const tile = page
.getByTestId("widget-frame")
.filter({ has: page.getByTestId("clock-time") })
await expect(tile.getByTestId("widget-issue")).toHaveCount(0)
})
test("a control reads back what it published", async ({ page }) => {
await openPanel(page)
const boost = page.getByRole("button", { name: "Boost" })
await boost.click()
await expect(boost).toHaveAttribute("aria-pressed", "true")
// The latching button names its state; two presses are a round trip.
const lamp = page.getByRole("button", { name: "Lamp" })
await expect(lamp).toHaveText("Off")
await lamp.click()
await expect(lamp).toHaveText("On")
await lamp.click()
await expect(lamp).toHaveText("Off")
})
test("an unbound widget says so and takes nothing down", async ({ page }) => {
const crashes: string[] = []
page.on("pageerror", (error) => crashes.push(error.message))
await openPanel(page)
await expect(page.getByText("Pick a message.")).toBeVisible()
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
expect(crashes, "the panel threw").toEqual([])
})
/**
* uPlot draws the axis title into the canvas, so there is nothing in the DOM
* to assert. What can be checked is that the setting survives: the panel is
* where it is written, and a reload is what proves it was stored.
*/
test("a chart's axis title is kept", async ({ page }) => {
await page.goto(`/dashboards/${dashboardName}?edit=true`)
const chart = page
.getByTestId("widget-frame")
.filter({ hasText: "Trend" })
.first()
await chart.waitFor({ timeout: 15000 })
await chart.click()
const field = page.locator('div:has(> label:text-is("Y axis title")) > input')
await expect(field).toBeVisible()
await field.fill("kW")
// Longer than the editor sits on an edit before saving it.
await page.waitForTimeout(2000)
await page.reload()
await chart.waitFor({ timeout: 15000 })
await chart.click()
await expect(field).toHaveValue("kW")
})
for (const scheme of ["light", "dark"] as const) {
test.describe(`${scheme} theme`, () => {
test.use({ colorScheme: scheme })
test(`the panel reads in ${scheme}`, async ({ page }) => {
await openPanel(page)
await expect(page.getByTestId("bar-inner")).toBeVisible()
await page.screenshot({ path: `screenshots/widgets/${scheme}.png` })
})
})
}