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", "", ) })