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
+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` })
})
})
}