Files
app/frontend/tests/editor.spec.ts
T
stroblmeandClaude Opus 5 7e8fd1182e Put the dashboard panels away on a click off the widgets
Mirrors the flow editor's onPaneClick: clicking the canvas margin or the
empty surface closes the widget settings and the dashboard panel. Widget
frames, grid resize handles and menus portalled out of the canvas keep it
open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 11:19:30 +02:00

204 lines
6.4 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The dashboard editor's quiet failures.
*
* Two are about a placement that nothing complains about: the grid library
* corrects a stored layout against the columns alone, so shrinking the canvas
* hides whatever now falls past its bottom edge; and the header that drags a
* widget used to also select it, so every drop opened the settings panel.
*
* The third is what a widget drawn without its title is dragged by, since the
* header it used to be dragged by is the thing that is gone.
*/
const dashboardName = `test_editor_${Date.now().toString(36)}`
/** The layout the editor is opened on. `below` cannot compact above `top`. */
const WIDGETS = [
{
id: "top",
type: "clock",
title: "Top",
layout: { lg: { x: 0, y: 0, w: 8, h: 4 } },
config: {},
},
{
id: "below",
type: "clock",
title: "Below",
layout: { lg: { x: 0, y: 8, w: 4, h: 2 } },
config: {},
},
]
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
// The working copy, which is what a new dashboard may be: the editor reads
// the draft, so the setup writes one rather than publishing.
const dashboard = await (
await api(page, `/dashboards/${dashboardName}?draft=true`)
).json()
dashboard.columns = 12
dashboard.canvas_width = 1920
dashboard.canvas_height = 1080
dashboard.widgets = WIDGETS
await api(page, `/dashboards/${dashboardName}`, {
method: "PUT",
data: dashboard,
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/dashboards/${dashboardName}`])
})
/** Where each widget is stored right now, as the editor last saved it. */
async function placements(page: Page) {
const doc = await (
await api(page, `/dashboards/${dashboardName}?draft=true`)
).json()
const widgets = doc.widgets as {
id: string
layout: { lg: { x: number; y: number } }
}[]
return Object.fromEntries(
widgets.map((widget) => [widget.id, widget.layout.lg]),
)
}
async function openEditor(page: Page) {
await page.goto(`/dashboards/${dashboardName}?edit=true`)
await page
.getByTestId("widget-frame")
.first()
.waitFor({ state: "visible", timeout: 15000 })
}
test("shrinking the canvas warns about what falls off it, and re-packs", async ({
page,
}) => {
await openEditor(page)
const notice = page.getByTestId("canvas-clipped")
await expect(notice).toBeHidden()
// Where `below` sits before the reflow — anywhere but the top shelf.
const before = (await placements(page)).below.y
expect(
before,
"the setup layout was already packed across the columns",
).toBeGreaterThan(0)
await page.getByTestId("edit-dashboard").click()
// 400px is four grid rows, which the four-row `top` fills on its own.
await page.getByTestId("canvas-height").fill("400")
await expect(notice).toBeVisible()
await expect(notice).toContainText("1 widget")
await page.getByTestId("reflow-canvas").click()
await expect(notice).toBeHidden()
// A hidden notice on its own could just be a re-render. The stored draft is
// what a panel will read, so that is what has to have moved.
await expect
.poll(async () => (await placements(page)).below.y, {
message: "the reflow was never saved",
timeout: 10000,
})
.toBeLessThan(before)
})
test("the drag handle moves a widget without selecting it", async ({
page,
}) => {
await openEditor(page)
const frame = page.getByTestId("widget-frame").filter({ hasText: "Top" })
const settings = page.getByTestId("widget-settings")
// A press on the grip is the start of a move; the click ending a drag must
// not open the panel behind it.
await frame.locator(".widget-grip").click()
await expect(settings).toBeHidden()
// The other half: selection still works, it just lives on the body now.
await frame.click()
await expect(settings).toBeVisible()
})
test("clicking off the widgets puts the settings panel away", async ({
page,
}) => {
await openEditor(page)
const settings = page.getByTestId("widget-settings")
await page.getByTestId("widget-frame").filter({ hasText: "Top" }).click()
await expect(settings).toBeVisible()
// The margin around the canvas is not a widget, the way the flow pane is not
// a node.
await page.getByTestId("dashboard-canvas").click({ position: { x: 8, y: 8 } })
await expect(settings).toBeHidden()
})
test("a widget without its title can still be dragged and picked", async ({
page,
}) => {
await openEditor(page)
const frame = page.getByTestId("widget-frame").filter({ hasText: "Below" })
const settings = page.getByTestId("widget-settings")
await frame.click()
await expect(settings).toBeVisible()
await page.getByTestId("widget-show-title").click()
// The title is gone from the tile — and so is the header that used to be the
// handle, so the editor grows one of its own rather than losing the widget.
const untitled = page
.getByTestId("widget-frame")
.filter({ hasNotText: "Top" })
await expect(untitled).not.toContainText("Below")
await expect(untitled.locator(".widget-grip")).toBeVisible()
await untitled.locator(".widget-grip").click()
// A press on the grip is a move, not a pick — as it is with a header.
await expect(page.getByTestId("widget-settings")).toBeVisible()
})
test("a palette is pasted as a link and read as roles", async ({ page }) => {
await openEditor(page)
await page.getByTestId("edit-dashboard").click()
await page
.getByTestId("dashboard-palette-input")
.fill("https://coolors.co/palette/264653-2a9d8f-e9c46a-f4a261-e76f51")
const swatches = page.getByTestId("dashboard-palette").getByRole("img")
await expect(swatches).toHaveCount(5)
await expect(swatches.first()).toHaveAttribute("aria-label", /Background/)
// The roles are positional, so turning the list is how a palette that
// arrived in the wrong order is put right.
await page.getByTestId("dashboard-palette-rotate").click()
await expect(swatches.first()).toHaveAttribute(
"aria-label",
/Background #2a9d8f/,
)
// And the canvas beside the panel wears it.
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-palette",
"",
)
})