Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { api, apiPage, deleteAll } from "./utils/api"
|
||||
|
||||
/**
|
||||
* Every screen at a phone's width, checking the one thing that is easy to
|
||||
* break and hard to notice: a page that scrolls sideways.
|
||||
*
|
||||
* The rules this enforces are in the root `DESIGN-GUIDELINES.md` → Responsive.
|
||||
* A wide table, chart or dock is allowed to scroll inside its own box; none of
|
||||
* them may widen the page around it.
|
||||
*/
|
||||
|
||||
const flowName = `test_mobile_${Date.now().toString(36)}`
|
||||
const dashboardName = `${flowName}_panel`
|
||||
|
||||
test.describe.configure({ mode: "serial" })
|
||||
|
||||
/**
|
||||
* Does anything on this page stick out past the viewport?
|
||||
*
|
||||
* Two signals, because either can hide the other. A page that scrolls
|
||||
* sideways fails `scrollWidth`; one that merely *contains* something too wide
|
||||
* makes the browser widen the layout viewport and zoom the whole page out
|
||||
* instead, which shows up only as `innerWidth` no longer being the device's.
|
||||
*/
|
||||
async function fits(page: Page): Promise<{ inner: number; scroll: number }> {
|
||||
return page.evaluate(() => ({
|
||||
inner: window.innerWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}))
|
||||
}
|
||||
|
||||
async function expectFits(page: Page, where: string) {
|
||||
const width = page.viewportSize()?.width ?? 0
|
||||
const { inner, scroll } = await fits(page)
|
||||
expect(inner, `${where} is too wide for the viewport`).toBe(width)
|
||||
expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner)
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await apiPage(browser)
|
||||
|
||||
// A chain and a fork, so the canvas has a graph worth laying out rather
|
||||
// than a single node that fits anywhere.
|
||||
await api(page, `/flows/${flowName}`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
name: flowName,
|
||||
title: "Mobile",
|
||||
nodes: [
|
||||
{
|
||||
id: "source",
|
||||
type: "python",
|
||||
provides: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
type: "python",
|
||||
requires: [{ name: "reading", dtype: "float" }],
|
||||
provides: [{ name: "scaled", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "left",
|
||||
type: "python",
|
||||
requires: [{ name: "scaled", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
type: "python",
|
||||
requires: [{ name: "scaled", dtype: "float" }],
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
},
|
||||
})
|
||||
const saved = await (await api(page, `/flows/${flowName}`)).json()
|
||||
await api(page, `/flows/${flowName}/publish`, {
|
||||
method: "POST",
|
||||
data: { version: saved.definition.version },
|
||||
})
|
||||
|
||||
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
|
||||
const dashboard = await (
|
||||
await api(page, `/dashboards/${dashboardName}`)
|
||||
).json()
|
||||
dashboard.pages[0].sections[0].widgets = [
|
||||
{
|
||||
id: "top",
|
||||
type: "stat",
|
||||
title: "Top",
|
||||
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
|
||||
config: { message: `${flowName}.reading` },
|
||||
},
|
||||
{
|
||||
id: "beside",
|
||||
type: "stat",
|
||||
title: "Beside",
|
||||
layout: { lg: { x: 3, y: 0, w: 3, h: 2 } },
|
||||
config: { message: `${flowName}.scaled` },
|
||||
},
|
||||
]
|
||||
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}`,
|
||||
])
|
||||
})
|
||||
|
||||
test("home fits the viewport", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
await page
|
||||
.getByText(/Flow activity/i)
|
||||
.first()
|
||||
.waitFor({ timeout: 15000 })
|
||||
await expectFits(page, "home")
|
||||
})
|
||||
|
||||
test("the overviews fit the viewport", async ({ page }) => {
|
||||
for (const path of ["/flows", "/dashboards"]) {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState("networkidle")
|
||||
await expectFits(page, path)
|
||||
}
|
||||
})
|
||||
|
||||
/** Where React Flow put a node in the graph, out of its wrapper transform. */
|
||||
async function nodeAt(page: Page, id: string) {
|
||||
const style = await page
|
||||
.locator(`.react-flow__node[data-id="${id}"]`)
|
||||
.evaluate((el) => (el as HTMLElement).style.transform)
|
||||
const [x, y] = [...style.matchAll(/-?[\d.]+/g)].map((m) => Number(m[0]))
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
test("the flow editor fits, and its dock is reachable", async ({ page }) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
await page.waitForSelector(".react-flow__node")
|
||||
await expectFits(page, "the flow editor")
|
||||
|
||||
// A phone has height to spare and no width, so the graph runs downwards:
|
||||
// what a node feeds sits below it, not beside it.
|
||||
const source = await nodeAt(page, "source")
|
||||
const scale = await nodeAt(page, "scale")
|
||||
expect(scale.y, "the graph does not run top to bottom").toBeGreaterThan(
|
||||
source.y,
|
||||
)
|
||||
expect(Math.abs(scale.x - source.x)).toBeLessThan(200)
|
||||
|
||||
// The dock used to overflow a phone, which put the buttons at its ends
|
||||
// outside the shell's `overflow-hidden` and made them unclickable.
|
||||
for (const id of ["add-node", "run-flow", "edit-flow", "publish-flow"]) {
|
||||
const box = await page.getByTestId(id).boundingBox()
|
||||
expect(box, `${id} is not on screen`).not.toBeNull()
|
||||
const width = page.viewportSize()?.width ?? 0
|
||||
expect(box!.x, `${id} starts off the left edge`).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
box!.x + box!.width,
|
||||
`${id} runs off the right edge`,
|
||||
).toBeLessThanOrEqual(width)
|
||||
}
|
||||
})
|
||||
|
||||
test("a dashboard stacks instead of shrinking", async ({ page }) => {
|
||||
await page.goto(`/dashboards/${dashboardName}`)
|
||||
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
||||
await expectFits(page, "the dashboard editor")
|
||||
|
||||
// Side by side on a panel, one under the other here.
|
||||
const first = await page.getByTestId("widget-frame").first().boundingBox()
|
||||
const second = await page.getByTestId("widget-frame").nth(1).boundingBox()
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1)
|
||||
})
|
||||
|
||||
test("the panel view fits the viewport", async ({ page }) => {
|
||||
await page.goto(`/view/${dashboardName}`)
|
||||
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
||||
await expectFits(page, "the panel view")
|
||||
})
|
||||
Reference in New Issue
Block a user