One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
import { expect, test } from "@playwright/test"
|
|
import { api, deleteAll } from "./utils/api"
|
|
|
|
/**
|
|
* Editing writes a draft; only publishing hands it to the engine. The two
|
|
* things worth proving here are that the engine ignores a draft until it is
|
|
* published, and that a second client cannot quietly overwrite the first.
|
|
*/
|
|
|
|
const flowName = `test_draft_${Date.now().toString(36)}`
|
|
|
|
test.use({ storageState: "playwright/.auth/user.json" })
|
|
|
|
test.describe.configure({ mode: "serial" })
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(browser, [`/flows/${flowName}`])
|
|
})
|
|
|
|
test("a draft stays off the engine until it is published", async ({ page }) => {
|
|
await page.goto("/flows")
|
|
await page.getByTestId("new-flow-name").fill(flowName)
|
|
await page.getByTestId("create-flow").click()
|
|
await page.waitForURL(`/flows/${flowName}`)
|
|
|
|
await page.getByTestId("add-node").click()
|
|
await page
|
|
.getByRole("option", { name: /function/i })
|
|
.first()
|
|
.click()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(1)
|
|
// Adding a node opens its panel, and the floating chrome steps aside for it.
|
|
await page.keyboard.press("Escape")
|
|
|
|
await expect(page.getByTestId("publish-flow")).toBeVisible()
|
|
|
|
// Nothing of this flow is loaded while it is only a draft.
|
|
await page.waitForTimeout(1500)
|
|
const before = await (await api(page, `/flows/${flowName}/state`)).json()
|
|
expect(before.nodes).toHaveLength(0)
|
|
|
|
await page.getByTestId("publish-flow").click()
|
|
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
|
|
|
const after = await (await api(page, `/flows/${flowName}/state`)).json()
|
|
expect(after.nodes.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("a save against a version someone else moved on from is refused", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
|
|
// Stand in for a second client that saved first.
|
|
const first = await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: { ...detail.definition, title: "Theirs" },
|
|
})
|
|
expect(first.ok()).toBeTruthy()
|
|
|
|
const stale = await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: { ...detail.definition, title: "Mine" },
|
|
})
|
|
expect(stale.status()).toBe(409)
|
|
expect((await stale.json()).detail.current_version).toBe(
|
|
detail.definition.version + 1,
|
|
)
|
|
})
|
|
|
|
test("discarding a draft goes back to the published flow", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
await page.getByTestId("edit-flow").click()
|
|
await page.getByTestId("discard-draft").click()
|
|
await page.getByTestId("confirm-discard-draft").click()
|
|
|
|
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
expect(detail.has_draft).toBe(false)
|
|
expect(detail.definition.title).not.toBe("Theirs")
|
|
})
|