Say when a widget falls off the canvas, and offer to pack it back

Shrinking a dashboard's canvas silently clipped whatever now fell past the
bottom edge: `maxRows` constrains a drag and nothing else, so a stored placement
is corrected against the column count alone. Nothing warned, and nothing offered
a way out.

The remedy is a notice rather than a reflow, because the canvas height is
written on every keystroke — typing 400 passes through 4 and 40, and anything
that moved widgets would flatten the arrangement while the number was still
being typed. The notice carries the reflow as its one button, and packing is
sideways because the grid already compacts vertically: nothing below the canvas
has room above it.

Dropping a widget also selected it, which opened its panel, which rescaled the
canvas under the pointer. The drag handle was simply missing from the selector
that already exempts the resize handle — which is why resizing never had this
problem.

Also: an icon rule's caption could only be set through the API, and the panel
rail drew two letters where a dashboard can now carry a lucide icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 14:33:12 +02:00
co-authored by Claude Opus 5
parent e8a818a50b
commit 2d7d66600d
4 changed files with 250 additions and 8 deletions
+133
View File
@@ -0,0 +1,133 @@
import { expect, type Page, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The dashboard editor's two quiet failures.
*
* Both 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.
*/
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.pages[0].sections[0].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.pages[0].sections[0].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()
})