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:
@@ -28,3 +28,10 @@ translucent `AppSidebar` (`variant="floating"`, `bg-card/80 backdrop-blur-md`).
|
|||||||
The flow editor floats its chrome over a full-bleed node canvas using the same
|
The flow editor floats its chrome over a full-bleed node canvas using the same
|
||||||
frosted-surface model. See DESIGN-GUIDELINES.md → Shells and → Overlay surfaces
|
frosted-surface model. See DESIGN-GUIDELINES.md → Shells and → Overlay surfaces
|
||||||
& content chips.
|
& content chips.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
One breakpoint, `md` (768px), and a phone inspects rather than arranges: no
|
||||||
|
dragging, no placing, no resizing. The rules that keep it that way — and the
|
||||||
|
"nothing scrolls horizontally" check that enforces them — are in
|
||||||
|
DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
|||||||
+8
-8
@@ -13,19 +13,21 @@ should reopen it.
|
|||||||
|
|
||||||
### To be sorted
|
### To be sorted
|
||||||
|
|
||||||
|
- BUG/UI dots and rings of nodes in the brain graph are not pixel-perfect centered
|
||||||
|
- BUG/UI rings of ndoes in the brain graph should be come a bit thinner and edges a bit thicker. Also ensure that trigger animations of nodes are still visible. We could use make the ring pulse instead of the existing effect
|
||||||
|
- BUG/UI HEALTH section shows 1 flow(s) cannot run but boxes below show e.g. 5/5 flows running
|
||||||
|
- FEAT/UI we should highlight failing nodes accordingly in the flow view
|
||||||
- BUG/UI when a dashboard widget is selectd, the border does not cleanly draw on the left side of the widget
|
- BUG/UI when a dashboard widget is selectd, the border does not cleanly draw on the left side of the widget
|
||||||
- BUG/UI there is currently no option to discard changes (in dashboard or flow viewport); we should make add a "X" button to discard and replace "Publish" by the already existing checkmark button which is either clickable when there are unpublished changes or unclickable when the changes have been applied (but the icon stays). The discard button should only show when there are changes (hidden else)
|
|
||||||
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
|
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
|
||||||
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
|
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
|
||||||
- BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static (holds true for desktop as well); always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length. This should also remove the ability to drag nodes around; their position is fixed by an algorithm. This design choice is what enforces small atomic flows (different from nodered) 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it
|
|
||||||
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
|
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
|
||||||
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
|
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
|
||||||
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
|
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
|
||||||
- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there)
|
|
||||||
- FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted.
|
- FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted.
|
||||||
- CHORE/UI the brain's hover labels have no touch equivalent — a tap navigates to the flow, and there is no hover to reveal a name first. The native `title` carries it on desktop only.
|
|
||||||
- BUG/UI slightly increase the margin between the top of a graph in the node/flow panel and the consumer/producer field
|
|
||||||
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
|
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
|
||||||
|
- CHORE/UI: `layoutGraph` treats every node as 220×56 rather than measuring, because feeding a measurement back into the layout oscillates. A node wider than that crowds its neighbours; take the sizes from `node.measured` once they have settled if it shows.
|
||||||
|
- FEAT/UI/MOBILE: a rank of many nodes — a connector feeding eight dashboard tiles — is thousands of pixels wide however the graph is turned, so on a phone the fit shrinks it past reading. The layout is right and the flow is simply too big for the screen; a "one rank at a time" reading mode, or wrapping a wide rank, is what would make it legible.
|
||||||
|
- CHORE/UI: an edge's value chip sits at the bezier midpoint while the layout reserves its room at dagre's label rank. The two agree closely enough today; if chips ever pile up, take the position from the layout instead.
|
||||||
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
|
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
|
||||||
|
|
||||||
### Persistence and databases
|
### Persistence and databases
|
||||||
@@ -163,10 +165,9 @@ as an em dash.
|
|||||||
- CHORE/UX: dropping a widget also selects it, which opens its panel — which rescales the canvas the instant you let go. Correct, but it lurches; either leave the panel closed on a drag-release or animate the scale.
|
- CHORE/UX: dropping a widget also selects it, which opens its panel — which rescales the canvas the instant you let go. Correct, but it lurches; either leave the panel closed on a drag-release or animate the scale.
|
||||||
- CHORE/UI: `ROW_HEIGHT` is a fixed 80px while column width follows the canvas, so a 1920-wide panel at 12 columns has 160×80 cells. If that reads too wide, the row height could derive from the canvas too.
|
- CHORE/UI: `ROW_HEIGHT` is a fixed 80px while column width follows the canvas, so a 1920-wide panel at 12 columns has 160×80 cells. If that reads too wide, the row height could derive from the canvas too.
|
||||||
- FEAT/UI: multi-page and multi-section dashboards have no UI. The backend has `PageDef`/`SectionDef` and rename; the editor only ever edits `sectionsOf(page)[0]`, so nothing can create a second page.
|
- FEAT/UI: multi-page and multi-section dashboards have no UI. The backend has `PageDef`/`SectionDef` and rename; the editor only ever edits `sectionsOf(page)[0]`, so nothing can create a second page.
|
||||||
- FEAT/UI: only `layout.lg` is ever written. Below `lg` the view stacks widgets full width in CSS, so `md`/`sm` stay unused until a per-breakpoint editor exists.
|
- CHORE/UI: only `layout.lg` is ever written, and `md`/`sm` stay unwritten by decision — a phone stacks the widgets (`.widget-stacked`) rather than carrying an arrangement of its own, since arranging is not a phone feature. The keys stay in the schema for a panel that one day wants a second size.
|
||||||
- PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer.
|
- PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer.
|
||||||
- CHORE/UI: opening edit mode on a dashboard whose widgets predate placement writes the migrated positions immediately, bumping the version once.
|
- CHORE/UI: opening edit mode on a dashboard whose widgets predate placement writes the migrated positions immediately, bumping the version once.
|
||||||
- FEAT/UI: `POST /dashboards/{name}/discard` has no button. The flow settings panel offers "discard draft"; the dashboard settings panel does not, so an unwanted edit can only be undone by hand or by publishing it.
|
|
||||||
- CHORE/API: creating a dashboard publishes it straight away (an empty document goes to the panels), while a new flow starts as a draft. Keeps `read`/`list` free of a never-published case, at the cost of the asymmetry.
|
- CHORE/API: creating a dashboard publishes it straight away (an empty document goes to the panels), while a new flow starts as a draft. Keeps `read`/`list` free of a never-published case, at the cost of the asymmetry.
|
||||||
- PERF/UI: "Publish all" reads each document's detail for the version its publish must match, so a click is 2N requests. A bulk endpoint, or a `version` on the summaries, would make it one.
|
- PERF/UI: "Publish all" reads each document's detail for the version its publish must match, so a click is 2N requests. A bulk endpoint, or a `version` on the summaries, would make it one.
|
||||||
|
|
||||||
@@ -208,7 +209,6 @@ Open on purpose. Each names what should bring it back.
|
|||||||
- CHORE/FLOW: shared node sources bypass the draft/publish split. Editing one writes the library copy and reloads immediately, since the code is not any single flow's to hold back. Deliberate, but it means a shared node is the one thing publish does not gate.
|
- CHORE/FLOW: shared node sources bypass the draft/publish split. Editing one writes the library copy and reloads immediately, since the code is not any single flow's to hold back. Deliberate, but it means a shared node is the one thing publish does not gate.
|
||||||
- CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves to 2.x.
|
- CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves to 2.x.
|
||||||
- CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks.
|
- CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks.
|
||||||
- FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a node that sits between the lane and the node it wires to. Readable, but a routed edge would be tidier.
|
|
||||||
- FEAT/UI: the node-panel and edge trend curves take no range, unlike the health block. They are drawn from a Redis ring of the last 120 values per message, which has no window to ask for — a hover caption names what the curve covers instead of a picker promising a span nothing can serve. Reopen if per-message history ever gains a time window.
|
- FEAT/UI: the node-panel and edge trend curves take no range, unlike the health block. They are drawn from a Redis ring of the last 120 values per message, which has no window to ask for — a hover caption names what the curve covers instead of a picker promising a span nothing can serve. Reopen if per-message history ever gains a time window.
|
||||||
- FEAT/UI: an e-ink rendering profile for a dashboard — motion off, hover-only affordances resolved to something visible, high-contrast palette, thick strokes, and a repaint cadence low enough for a display that takes a second to settle. Reopen when a panel with such a display is actually hung.
|
- FEAT/UI: an e-ink rendering profile for a dashboard — motion off, hover-only affordances resolved to something visible, high-contrast palette, thick strokes, and a repaint cadence low enough for a display that takes a second to settle. Reopen when a panel with such a display is actually hung.
|
||||||
- CHORE/INFRA: Postgres stays. The 2026-08 review rejected YugabyteDB/CockroachDB (multi-node cluster systems, ~4 GB+ RAM per node, against the small-server target — the scaling story is remote workers, not a distributed DB) and found merging Postgres into Redis or vice versa buys little: the stores hold disjoint data and both sit behind abstractions. SQLite would fit the single-instance design and drop a container; reopen if the home-install footprint becomes a product concern.
|
- CHORE/INFRA: Postgres stays. The 2026-08 review rejected YugabyteDB/CockroachDB (multi-node cluster systems, ~4 GB+ RAM per node, against the small-server target — the scaling story is remote workers, not a distributed DB) and found merging Postgres into Redis or vice versa buys little: the stores hold disjoint data and both sit behind abstractions. SQLite would fit the single-instance design and drop a container; reopen if the home-install footprint becomes a product concern.
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str
|
|||||||
errors, with a switch per flow
|
errors, with a switch per flow
|
||||||
- [x] Logs panel in the canvas dock, pause/resume beside Run, and replaying an
|
- [x] Logs panel in the canvas dock, pause/resume beside Run, and replaying an
|
||||||
edge's last message from the inspector
|
edge's last message from the inspector
|
||||||
|
- [x] 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 a flow document holds no positions:
|
||||||
|
a graph nobody can arrange is one worth keeping small, which is what
|
||||||
|
keeps flows atomic
|
||||||
|
- [x] Usable on a phone, and written down so it stays that way: one breakpoint
|
||||||
|
(`md`), a stacked dashboard instead of a shrunken wall panel, a dock that
|
||||||
|
wraps rather than overflows, and a Playwright project that fails the
|
||||||
|
build when a screen no longer fits. See DESIGN-GUIDELINES.md → Responsive
|
||||||
- [ ] Device assignment per node, selectable from compatible devices
|
- [ ] Device assignment per node, selectable from compatible devices
|
||||||
- [ ] Test-node affordance on the canvas
|
- [ ] Test-node affordance on the canvas
|
||||||
- [ ] User management screens
|
- [ ] User management screens
|
||||||
|
|||||||
@@ -24,20 +24,17 @@ def _validate_name(value: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class Position(BaseModel):
|
|
||||||
"""Where a node sits on the canvas."""
|
|
||||||
|
|
||||||
x: float = 0
|
|
||||||
y: float = 0
|
|
||||||
|
|
||||||
|
|
||||||
class NodeDef(BaseModel):
|
class NodeDef(BaseModel):
|
||||||
"""A node as stored: identity, placement, configuration and ports."""
|
"""A node as stored: identity, configuration and ports.
|
||||||
|
|
||||||
|
Deliberately no canvas position. The editor lays a flow out itself, so
|
||||||
|
where a node sits is a fact about the drawing rather than about the flow —
|
||||||
|
and a graph nobody can arrange is one worth keeping small.
|
||||||
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
type: str = "python"
|
type: str = "python"
|
||||||
title: str = ""
|
title: str = ""
|
||||||
position: Position = Position()
|
|
||||||
params: dict[str, Any] = Field(default_factory=dict)
|
params: dict[str, Any] = Field(default_factory=dict)
|
||||||
requires: list[MessageSpec] = Field(default_factory=list)
|
requires: list[MessageSpec] = Field(default_factory=list)
|
||||||
provides: list[MessageSpec] = Field(default_factory=list)
|
provides: list[MessageSpec] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ def a_flow(name: str = "demo") -> dict:
|
|||||||
{
|
{
|
||||||
"id": "sensor",
|
"id": "sensor",
|
||||||
"type": "python",
|
"type": "python",
|
||||||
"position": {"x": 0, "y": 0},
|
|
||||||
"provides": [{"name": "reading", "dtype": "float"}],
|
"provides": [{"name": "reading", "dtype": "float"}],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "logger",
|
"id": "logger",
|
||||||
"type": "python",
|
"type": "python",
|
||||||
"position": {"x": 240, "y": 0},
|
|
||||||
"requires": [{"name": "reading", "dtype": "float"}],
|
"requires": [{"name": "reading", "dtype": "float"}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.1.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -130,6 +131,10 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
||||||
|
|
||||||
|
"@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="],
|
||||||
|
|
||||||
|
"@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:ui": "bunx playwright test --ui"
|
"test:ui": "bunx playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.1.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
|||||||
@@ -42,6 +42,22 @@ export default defineConfig({
|
|||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
storageState: 'playwright/.auth/user.json',
|
storageState: 'playwright/.auth/user.json',
|
||||||
},
|
},
|
||||||
|
/* The mobile suite is the same app at a phone's width; running it here
|
||||||
|
too would only re-assert the desktop layout. */
|
||||||
|
testIgnore: /mobile\.spec\.ts/,
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Every screen at 393px, checking the one thing that is easy to break and
|
||||||
|
hard to notice: a page that scrolls sideways. See the Responsive section
|
||||||
|
of the root DESIGN-GUIDELINES.md. */
|
||||||
|
{
|
||||||
|
name: 'mobile',
|
||||||
|
use: {
|
||||||
|
...devices['Pixel 5'],
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
testMatch: /mobile\.spec\.ts/,
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -63,11 +79,6 @@ export default defineConfig({
|
|||||||
// dependencies: ['setup'],
|
// dependencies: ['setup'],
|
||||||
// },
|
// },
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
|
||||||
// {
|
|
||||||
// name: 'Mobile Chrome',
|
|
||||||
// use: { ...devices['Pixel 5'] },
|
|
||||||
// },
|
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Safari',
|
// name: 'Mobile Safari',
|
||||||
// use: { ...devices['iPhone 12'] },
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
|||||||
@@ -25,11 +25,23 @@ if (!EMAIL || !PASSWORD) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two shapes the app is drawn for: a desktop, and a phone. Below `md` it
|
||||||
|
* is a different layout rather than a narrower one — see the Responsive
|
||||||
|
* section of the root DESIGN-GUIDELINES.md — so it wants its own shots.
|
||||||
|
*/
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: "", viewport: { width: 1440, height: 900 } },
|
||||||
|
{ name: "mobile", viewport: { width: 390, height: 844 } },
|
||||||
|
]
|
||||||
|
|
||||||
/** Force the theme through the same storage key the pre-paint script reads. */
|
/** Force the theme through the same storage key the pre-paint script reads. */
|
||||||
async function withTheme(browser, theme) {
|
async function withTheme(browser, theme, viewport) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport,
|
||||||
colorScheme: theme,
|
colorScheme: theme,
|
||||||
|
isMobile: viewport.width < 768,
|
||||||
|
hasTouch: viewport.width < 768,
|
||||||
})
|
})
|
||||||
await context.addInitScript((t) => {
|
await context.addInitScript((t) => {
|
||||||
localStorage.setItem("fluksio-ui-theme", t)
|
localStorage.setItem("fluksio-ui-theme", t)
|
||||||
@@ -46,9 +58,10 @@ const browser = await chromium.launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const theme of ["light", "dark"]) {
|
for (const theme of ["light", "dark"]) {
|
||||||
const dir = `${OUT}/${theme}`
|
for (const { name, viewport } of VIEWPORTS) {
|
||||||
|
const dir = name ? `${OUT}/${theme}/${name}` : `${OUT}/${theme}`
|
||||||
await mkdir(dir, { recursive: true })
|
await mkdir(dir, { recursive: true })
|
||||||
const context = await withTheme(browser, theme)
|
const context = await withTheme(browser, theme, viewport)
|
||||||
const page = await context.newPage()
|
const page = await context.newPage()
|
||||||
|
|
||||||
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
|
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
|
||||||
@@ -82,6 +95,7 @@ for (const theme of ["light", "dark"]) {
|
|||||||
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`,
|
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1306,13 +1306,6 @@ export const NodeDef_InputSchema = {
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
position: {
|
|
||||||
'$ref': '#/components/schemas/Position',
|
|
||||||
default: {
|
|
||||||
x: 0,
|
|
||||||
y: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
params: {
|
||||||
additionalProperties: true,
|
additionalProperties: true,
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1360,7 +1353,11 @@ export const NodeDef_InputSchema = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['id'],
|
required: ['id'],
|
||||||
title: 'NodeDef',
|
title: 'NodeDef',
|
||||||
description: 'A node as stored: identity, placement, configuration and ports.'
|
description: `A node as stored: identity, configuration and ports.
|
||||||
|
|
||||||
|
Deliberately no canvas position. The editor lays a flow out itself, so
|
||||||
|
where a node sits is a fact about the drawing rather than about the flow —
|
||||||
|
and a graph nobody can arrange is one worth keeping small.`
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NodeDef_OutputSchema = {
|
export const NodeDef_OutputSchema = {
|
||||||
@@ -1379,13 +1376,6 @@ export const NodeDef_OutputSchema = {
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
position: {
|
|
||||||
'$ref': '#/components/schemas/Position',
|
|
||||||
default: {
|
|
||||||
x: 0,
|
|
||||||
y: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
params: {
|
||||||
additionalProperties: true,
|
additionalProperties: true,
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1433,7 +1423,11 @@ export const NodeDef_OutputSchema = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['id'],
|
required: ['id'],
|
||||||
title: 'NodeDef',
|
title: 'NodeDef',
|
||||||
description: 'A node as stored: identity, placement, configuration and ports.'
|
description: `A node as stored: identity, configuration and ports.
|
||||||
|
|
||||||
|
Deliberately no canvas position. The editor lays a flow out itself, so
|
||||||
|
where a node sits is a fact about the drawing rather than about the flow —
|
||||||
|
and a graph nobody can arrange is one worth keeping small.`
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NodeSourceSchema = {
|
export const NodeSourceSchema = {
|
||||||
@@ -1782,24 +1776,6 @@ export const PlacementSchema = {
|
|||||||
description: "Where a widget sits in its section's grid, in grid units."
|
description: "Where a widget sits in its section's grid, in grid units."
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PositionSchema = {
|
|
||||||
properties: {
|
|
||||||
x: {
|
|
||||||
type: 'number',
|
|
||||||
title: 'X',
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
type: 'number',
|
|
||||||
title: 'Y',
|
|
||||||
default: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'Position',
|
|
||||||
description: 'Where a node sits on the canvas.'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const PrivateUserCreateSchema = {
|
export const PrivateUserCreateSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
email: {
|
email: {
|
||||||
|
|||||||
@@ -447,13 +447,16 @@ export type NewPassword = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A node as stored: identity, placement, configuration and ports.
|
* A node as stored: identity, configuration and ports.
|
||||||
|
*
|
||||||
|
* Deliberately no canvas position. The editor lays a flow out itself, so
|
||||||
|
* where a node sits is a fact about the drawing rather than about the flow —
|
||||||
|
* and a graph nobody can arrange is one worth keeping small.
|
||||||
*/
|
*/
|
||||||
export type NodeDef_Input = {
|
export type NodeDef_Input = {
|
||||||
id: string;
|
id: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
position?: Position;
|
|
||||||
params?: {
|
params?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@@ -467,13 +470,16 @@ export type NodeDef_Input = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A node as stored: identity, placement, configuration and ports.
|
* A node as stored: identity, configuration and ports.
|
||||||
|
*
|
||||||
|
* Deliberately no canvas position. The editor lays a flow out itself, so
|
||||||
|
* where a node sits is a fact about the drawing rather than about the flow —
|
||||||
|
* and a graph nobody can arrange is one worth keeping small.
|
||||||
*/
|
*/
|
||||||
export type NodeDef_Output = {
|
export type NodeDef_Output = {
|
||||||
id: string;
|
id: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
position?: Position;
|
|
||||||
params?: {
|
params?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@@ -585,14 +591,6 @@ export type Placement = {
|
|||||||
h?: number;
|
h?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a node sits on the canvas.
|
|
||||||
*/
|
|
||||||
export type Position = {
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivateUserCreate = {
|
export type PrivateUserCreate = {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
||||||
@@ -27,6 +28,14 @@ import {
|
|||||||
} from "@/client"
|
} from "@/client"
|
||||||
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
|
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -40,6 +49,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { slideUp, transitions } from "@/lib/motion"
|
import { slideUp, transitions } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
@@ -59,7 +69,12 @@ import {
|
|||||||
widgetsOf,
|
widgetsOf,
|
||||||
} from "./DashboardView"
|
} from "./DashboardView"
|
||||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||||
import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries"
|
import {
|
||||||
|
dashboardKeys,
|
||||||
|
useDiscardDashboardDraft,
|
||||||
|
usePublishDashboard,
|
||||||
|
useSaveDashboard,
|
||||||
|
} from "./queries"
|
||||||
import {
|
import {
|
||||||
WIDGET_LABELS,
|
WIDGET_LABELS,
|
||||||
WIDGET_SIZES,
|
WIDGET_SIZES,
|
||||||
@@ -167,7 +182,10 @@ const same = (a: Layout, b: Layout) =>
|
|||||||
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
|
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
|
||||||
*
|
*
|
||||||
* View mode never mounts the grid library: a wall panel that only displays
|
* View mode never mounts the grid library: a wall panel that only displays
|
||||||
* should not pay for the code that lets someone drag things around.
|
* should not pay for the code that lets someone drag things around. Nor does a
|
||||||
|
* phone — arranging is not a phone feature, and `applyLayout` below writes
|
||||||
|
* whatever the grid reports, so a stacked layout reaching it would overwrite
|
||||||
|
* the arrangement the panel is meant to show.
|
||||||
*/
|
*/
|
||||||
export function DashboardEditor({
|
export function DashboardEditor({
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -182,10 +200,15 @@ export function DashboardEditor({
|
|||||||
const [pageId, setPageId] = useState<string | undefined>(
|
const [pageId, setPageId] = useState<string | undefined>(
|
||||||
() => pagesOf(dashboard)[0]?.id,
|
() => pagesOf(dashboard)[0]?.id,
|
||||||
)
|
)
|
||||||
|
// A phone reads the dashboard rather than arranges it, so the grid library
|
||||||
|
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
const stacked = useIsMobile()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const save = useSaveDashboard(dashboard.name)
|
const save = useSaveDashboard(dashboard.name)
|
||||||
const publish = usePublishDashboard(dashboard.name)
|
const publish = usePublishDashboard(dashboard.name)
|
||||||
|
const discard = useDiscardDashboardDraft(dashboard.name)
|
||||||
|
const [discardOpen, setDiscardOpen] = useState(false)
|
||||||
const { showErrorToast } = useCustomToast()
|
const { showErrorToast } = useCustomToast()
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
// The save that is already on its way, so a publish waits for it instead of
|
// The save that is already on its way, so a publish waits for it instead of
|
||||||
@@ -338,6 +361,27 @@ export function DashboardEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A widget that can be picked to open its settings. */
|
||||||
|
const pickable = (widget: WidgetDef, grip = false) => (
|
||||||
|
<WidgetFrame
|
||||||
|
title={widget.title}
|
||||||
|
issue={widgetIssue(widget)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
widget.id === selected && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
grip={grip}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||||
|
setSettingsOpen(false)
|
||||||
|
setSelected(widget.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||||
|
</WidgetFrame>
|
||||||
|
)
|
||||||
|
|
||||||
const body = !page ? (
|
const body = !page ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This dashboard has no pages yet.
|
This dashboard has no pages yet.
|
||||||
@@ -346,6 +390,17 @@ export function DashboardEditor({
|
|||||||
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
||||||
Nothing on this page yet. Add a widget from the bar below.
|
Nothing on this page yet. Add a widget from the bar below.
|
||||||
</p>
|
</p>
|
||||||
|
) : stacked ? (
|
||||||
|
// One column at the viewport's width. Edit mode still picks a widget and
|
||||||
|
// opens its settings; only the arrangement is missing.
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<DashboardView
|
||||||
|
dashboard={draft}
|
||||||
|
pageId={page.id}
|
||||||
|
stacked
|
||||||
|
renderWidget={edit ? pickable : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CanvasSurface dashboard={draft} dots={edit}>
|
<CanvasSurface dashboard={draft} dots={edit}>
|
||||||
{(scale) =>
|
{(scale) =>
|
||||||
@@ -372,25 +427,7 @@ export function DashboardEditor({
|
|||||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||||
>
|
>
|
||||||
{widgets.map((widget) => (
|
{widgets.map((widget) => (
|
||||||
<div key={widget.id}>
|
<div key={widget.id}>{pickable(widget, true)}</div>
|
||||||
<WidgetFrame
|
|
||||||
title={widget.title}
|
|
||||||
issue={widgetIssue(widget)}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
widget.id === selected && "ring-2 ring-primary",
|
|
||||||
)}
|
|
||||||
grip
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
|
||||||
setSettingsOpen(false)
|
|
||||||
setSelected(widget.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
|
||||||
</WidgetFrame>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</GridLayout>
|
</GridLayout>
|
||||||
)
|
)
|
||||||
@@ -439,7 +476,9 @@ export function DashboardEditor({
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transitions.emphasized}
|
transition={transitions.emphasized}
|
||||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
// Capped and wrapping, like the flow dock; see DESIGN-GUIDELINES.md
|
||||||
|
// → Responsive.
|
||||||
|
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||||
>
|
>
|
||||||
{edit ? (
|
{edit ? (
|
||||||
<>
|
<>
|
||||||
@@ -494,18 +533,55 @@ export function DashboardEditor({
|
|||||||
<TooltipContent>Dashboard settings</TooltipContent>
|
<TooltipContent>Dashboard settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Only while there is something to throw away. */}
|
||||||
|
{hasDraft ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
<Button
|
||||||
{save.isPending ? (
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={() => setDiscardOpen(true)}
|
||||||
|
aria-label="Discard the unpublished changes"
|
||||||
|
data-testid="discard-dashboard"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Discard the unpublished changes
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Saved state and publish are one control, as in the flow dock:
|
||||||
|
the glyph stays put and simply stops being pressable. */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={() => void publishDashboard()}
|
||||||
|
disabled={
|
||||||
|
!hasDraft || publish.isPending || save.isPending
|
||||||
|
}
|
||||||
|
aria-label="Publish this dashboard"
|
||||||
|
data-testid="publish-dashboard"
|
||||||
|
>
|
||||||
|
{save.isPending || publish.isPending ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{save.isPending
|
{publish.isPending
|
||||||
|
? "Publishing"
|
||||||
|
: save.isPending
|
||||||
? "Saving"
|
? "Saving"
|
||||||
: hasDraft
|
: hasDraft
|
||||||
? "Saved — publish to put it on the panels"
|
? "Saved — publish to put it on the panels"
|
||||||
@@ -513,20 +589,10 @@ export function DashboardEditor({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{hasDraft ? (
|
<Separator
|
||||||
<Button
|
orientation="vertical"
|
||||||
variant="outline"
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
size="sm"
|
/>
|
||||||
className="h-11 shrink-0 rounded-full md:h-8"
|
|
||||||
onClick={() => void publishDashboard()}
|
|
||||||
disabled={publish.isPending}
|
|
||||||
data-testid="publish-dashboard"
|
|
||||||
>
|
|
||||||
{publish.isPending ? "Publishing…" : "Publish"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -590,6 +656,38 @@ export function DashboardEditor({
|
|||||||
onDelete={() => remove.mutate()}
|
onDelete={() => remove.mutate()}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The dashboard goes back to what the panels are showing. What
|
||||||
|
you edited since is dropped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={discard.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setDiscardOpen(false)
|
||||||
|
// Leaving edit mode remounts the editor, which is how the
|
||||||
|
// published document replaces the working copy.
|
||||||
|
discard.mutate(undefined, {
|
||||||
|
onSuccess: () => setEdit(false),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
data-testid="confirm-discard-dashboard"
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export function SectionGrid({
|
|||||||
dashboard,
|
dashboard,
|
||||||
columns = DEFAULT_COLUMNS,
|
columns = DEFAULT_COLUMNS,
|
||||||
renderWidget,
|
renderWidget,
|
||||||
|
stacked,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
section: SectionDef_Output
|
section: SectionDef_Output
|
||||||
@@ -176,9 +177,20 @@ export function SectionGrid({
|
|||||||
dashboard: string
|
dashboard: string
|
||||||
columns?: number
|
columns?: number
|
||||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||||
|
/** One column at the viewport's width, for a phone. */
|
||||||
|
stacked?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const widgets = widgetsOf(section)
|
const all = widgetsOf(section)
|
||||||
|
// Stacked, the arrangement becomes a reading order, so it follows the rows
|
||||||
|
// the panel shows rather than the order widgets happened to be added in.
|
||||||
|
const widgets = stacked
|
||||||
|
? [...all].sort((a, b) => {
|
||||||
|
const left = placement(a)
|
||||||
|
const right = placement(b)
|
||||||
|
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
|
||||||
|
})
|
||||||
|
: all
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3">
|
<section className="grid gap-3">
|
||||||
{section.title ? (
|
{section.title ? (
|
||||||
@@ -187,7 +199,7 @@ export function SectionGrid({
|
|||||||
</h2>
|
</h2>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={cn("widget-grid", className)}
|
className={cn("widget-grid", stacked && "widget-stacked", className)}
|
||||||
data-placed={isPlaced(widgets) || undefined}
|
data-placed={isPlaced(widgets) || undefined}
|
||||||
style={{ "--widget-cols": columns } as React.CSSProperties}
|
style={{ "--widget-cols": columns } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
@@ -215,10 +227,13 @@ export function DashboardView({
|
|||||||
dashboard,
|
dashboard,
|
||||||
pageId,
|
pageId,
|
||||||
renderWidget,
|
renderWidget,
|
||||||
|
stacked,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
pageId?: string
|
pageId?: string
|
||||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||||
|
/** One column at the viewport's width, for a phone. */
|
||||||
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
const pages = pagesOf(dashboard)
|
const pages = pagesOf(dashboard)
|
||||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||||
@@ -254,6 +269,7 @@ export function DashboardView({
|
|||||||
dashboard={dashboard.name}
|
dashboard={dashboard.name}
|
||||||
columns={columnsOf(dashboard)}
|
columns={columnsOf(dashboard)}
|
||||||
renderWidget={renderWidget}
|
renderWidget={renderWidget}
|
||||||
|
stacked={stacked}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,10 @@
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
|
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
|
||||||
* panel can be matched to its own width. There is no responsive fallback: the
|
* panel can be matched to its own width. It does not reflow: the grid lives on
|
||||||
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
|
* a canvas of the panel's own pixel size, which is scaled to the viewport.
|
||||||
* viewport rather than reflowed into it.
|
* A phone gets `.widget-stacked` below instead, which is a different surface
|
||||||
|
* rather than a narrower version of this one.
|
||||||
*/
|
*/
|
||||||
.widget-grid {
|
.widget-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -46,6 +47,25 @@
|
|||||||
grid-row: var(--y) / span var(--h);
|
grid-row: var(--y) / span var(--h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One column, in reading order, at the viewport's own width — what a phone
|
||||||
|
* gets instead of a wall panel shrunk to 18%. Arranging is not a phone
|
||||||
|
* feature, so the stored x/w are dropped and only the height a widget asked
|
||||||
|
* for survives: a chart still needs its room, a stat still does not.
|
||||||
|
* See DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
*/
|
||||||
|
.widget-grid.widget-stacked,
|
||||||
|
.widget-grid.widget-stacked[data-placed] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-grid.widget-stacked .widget-cell {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-row: auto;
|
||||||
|
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* uPlot, routed through the tokens. Its own legend is the hover readout as
|
* uPlot, routed through the tokens. Its own legend is the hover readout as
|
||||||
* well — the value each line carried at the cursor — so it is styled as chart
|
* well — the value each line carried at the cursor — so it is styled as chart
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ export function usePublishDashboard(name: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Throw the unpublished edit away; the panels keep showing what they had. */
|
||||||
|
export function useDiscardDashboardDraft(name: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => DashboardsService.discardDashboardDraft({ name }),
|
||||||
|
onSuccess: (published) => {
|
||||||
|
queryClient.setQueryData(dashboardKeys.detail(name, true), published)
|
||||||
|
queryClient.setQueryData(dashboardKeys.detail(name), published)
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** What an input widget does: put a value into the graph.
|
/** What an input widget does: put a value into the graph.
|
||||||
*
|
*
|
||||||
* The widget names itself so the flow canvas can show the value arriving from
|
* The widget names itself so the flow canvas can show the value arriving from
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export function WidgetFrame({
|
|||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
|
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
|
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
|
||||||
<div
|
<div
|
||||||
|
data-testid="widget-frame"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type BrainNodeData = {
|
|||||||
size: number
|
size: number
|
||||||
/** Why this neuron cannot run, if validation found something. */
|
/** Why this neuron cannot run, if validation found something. */
|
||||||
issue?: string | null
|
issue?: string | null
|
||||||
|
/** Show the name without a pointer to hover with — see `BrainView`. */
|
||||||
|
revealed?: boolean
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,8 @@ const RING = 0.231
|
|||||||
const GAP = 0.077
|
const GAP = 0.077
|
||||||
|
|
||||||
function BrainNodeComponent({ data }: NodeProps) {
|
function BrainNodeComponent({ data }: NodeProps) {
|
||||||
const { label, kind, members, flows, size, issue } = data as BrainNodeData
|
const { label, kind, members, flows, size, issue, revealed } =
|
||||||
|
data as BrainNodeData
|
||||||
const emits = useGroupEmits(members)
|
const emits = useGroupEmits(members)
|
||||||
const failed = useGroupError(members)
|
const failed = useGroupError(members)
|
||||||
// Two faults, told apart the way the mark's two parts are: the ring is the
|
// Two faults, told apart the way the mark's two parts are: the ring is the
|
||||||
@@ -102,7 +105,9 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
|
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
|
||||||
* fill colour, not a text colour.
|
* fill colour, not a text colour.
|
||||||
*/}
|
*/}
|
||||||
<span className={cn("brain-label", problem && "brain-label-on")}>
|
<span
|
||||||
|
className={cn("brain-label", (problem || revealed) && "brain-label-on")}
|
||||||
|
>
|
||||||
<span className="max-w-[140px] truncate text-xs font-medium">
|
<span className="max-w-[140px] truncate text-xs font-medium">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
type SimulationNodeDatum,
|
type SimulationNodeDatum,
|
||||||
} from "d3-force"
|
} from "d3-force"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
import { useEffect, useMemo } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
import type { BrainGraph } from "@/client"
|
import type { BrainGraph } from "@/client"
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { scaleIn } from "@/lib/motion"
|
import { scaleIn } from "@/lib/motion"
|
||||||
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
||||||
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
||||||
@@ -162,6 +163,23 @@ function BrainCanvas() {
|
|||||||
[data],
|
[data],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A name is revealed on hover, which a finger does not have. On a phone the
|
||||||
|
// first tap says which neuron this is and the second follows it — kept out
|
||||||
|
// of the layout memo so revealing one does not re-run the simulation.
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [revealed, setRevealed] = useState<string | null>(null)
|
||||||
|
const shown = useMemo(
|
||||||
|
() =>
|
||||||
|
revealed
|
||||||
|
? nodes.map((node) =>
|
||||||
|
node.id === revealed
|
||||||
|
? { ...node, data: { ...node.data, revealed: true } }
|
||||||
|
: node,
|
||||||
|
)
|
||||||
|
: nodes,
|
||||||
|
[nodes, revealed],
|
||||||
|
)
|
||||||
|
|
||||||
// A rebuild lays the whole graph out afresh, so the viewport someone was
|
// A rebuild lays the whole graph out afresh, so the viewport someone was
|
||||||
// looking through no longer frames anything. Only when the set of neurons
|
// looking through no longer frames anything. Only when the set of neurons
|
||||||
// actually changed: a value arriving must not move the canvas.
|
// actually changed: a value arriving must not move the canvas.
|
||||||
@@ -186,7 +204,7 @@ function BrainCanvas() {
|
|||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
>
|
>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={shown}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
@@ -206,10 +224,15 @@ function BrainCanvas() {
|
|||||||
panOnDrag={false}
|
panOnDrag={false}
|
||||||
preventScrolling={false}
|
preventScrolling={false}
|
||||||
onNodeClick={(_event, node) => {
|
onNodeClick={(_event, node) => {
|
||||||
|
if (isMobile && revealed !== node.id) {
|
||||||
|
setRevealed(node.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
const [flow] = (node.data as BrainNodeData).flows
|
const [flow] = (node.data as BrainNodeData).flows
|
||||||
if (flow)
|
if (flow)
|
||||||
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
||||||
}}
|
}}
|
||||||
|
onPaneClick={() => setRevealed(null)}
|
||||||
className="brain-flat h-full w-full"
|
className="brain-flat h-full w-full"
|
||||||
>
|
>
|
||||||
{/* The same dot grid the editor's canvas uses, quieter and masked back
|
{/* The same dot grid the editor's canvas uses, quieter and masked back
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"
|
|||||||
import { LayoutDashboard, Workflow } from "lucide-react"
|
import { LayoutDashboard, Workflow } from "lucide-react"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { EndpointNodeData } from "./endpoints"
|
import type { EndpointNodeData } from "./endpoints"
|
||||||
|
|
||||||
@@ -22,15 +23,17 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
|||||||
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
||||||
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
||||||
const messages = [...provides, ...requires]
|
const messages = [...provides, ...requires]
|
||||||
|
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
const vertical = useIsMobile()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// Padding keeps the text off the connector dot, which sits on the edge.
|
// Padding keeps the text off the connector dot, which sits on the edge.
|
||||||
"flex max-w-48 cursor-grab items-center gap-2 px-3 py-1",
|
"flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
|
||||||
// Quiet at rest so the logic reads first; legible when reached for.
|
// Quiet at rest so the logic reads first; legible when reached for.
|
||||||
"text-muted-foreground/55 transition-colors",
|
"text-muted-foreground/55 transition-colors",
|
||||||
"hover:text-foreground active:cursor-grabbing",
|
"hover:text-foreground",
|
||||||
selected && "text-foreground",
|
selected && "text-foreground",
|
||||||
)}
|
)}
|
||||||
title={messages.join("\n")}
|
title={messages.join("\n")}
|
||||||
@@ -41,7 +44,7 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
|||||||
key={`in-${message}`}
|
key={`in-${message}`}
|
||||||
type="target"
|
type="target"
|
||||||
id={message}
|
id={message}
|
||||||
position={Position.Left}
|
position={vertical ? Position.Top : Position.Left}
|
||||||
className="!border-border !bg-card"
|
className="!border-border !bg-card"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -50,7 +53,7 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
|||||||
key={`out-${message}`}
|
key={`out-${message}`}
|
||||||
type="source"
|
type="source"
|
||||||
id={message}
|
id={message}
|
||||||
position={Position.Right}
|
position={vertical ? Position.Bottom : Position.Right}
|
||||||
className="!border-border !bg-card"
|
className="!border-border !bg-card"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
StepForward,
|
StepForward,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
|
X,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -47,8 +48,11 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
|||||||
* affordance on this view; everything else stays quiet.
|
* affordance on this view; everything else stays quiet.
|
||||||
*
|
*
|
||||||
* Everything the flow bar used to carry is here too — whether the work is
|
* Everything the flow bar used to carry is here too — whether the work is
|
||||||
* saved, the flow's own settings, and putting it live — so the top of the
|
* saved, the flow's own settings, and putting it live or throwing it away —
|
||||||
* canvas is left to say which flow this is.
|
* so the top of the canvas is left to say which flow this is.
|
||||||
|
*
|
||||||
|
* It wraps rather than overflows, and drops the zoom controls on a phone; see
|
||||||
|
* DESIGN-GUIDELINES.md → Responsive.
|
||||||
*/
|
*/
|
||||||
export function FlowDock({
|
export function FlowDock({
|
||||||
flow,
|
flow,
|
||||||
@@ -68,6 +72,7 @@ export function FlowDock({
|
|||||||
onFocusNode,
|
onFocusNode,
|
||||||
onEditFlow,
|
onEditFlow,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
onDiscard,
|
||||||
}: {
|
}: {
|
||||||
flow: string
|
flow: string
|
||||||
issues: ValidationIssue[]
|
issues: ValidationIssue[]
|
||||||
@@ -87,6 +92,7 @@ export function FlowDock({
|
|||||||
onFocusNode: (nodeId: string) => void
|
onFocusNode: (nodeId: string) => void
|
||||||
onEditFlow: () => void
|
onEditFlow: () => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
|
onDiscard: () => void
|
||||||
}) {
|
}) {
|
||||||
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
||||||
const connected = useLiveConnection()
|
const connected = useLiveConnection()
|
||||||
@@ -98,7 +104,10 @@ export function FlowDock({
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transitions.emphasized}
|
transition={transitions.emphasized}
|
||||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
||||||
|
// put the buttons at its ends out of reach on a phone rather than merely
|
||||||
|
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -116,12 +125,17 @@ export function FlowDock({
|
|||||||
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* A phone pinches to zoom and the graph fits itself, so these three
|
||||||
|
would only be taking room the rest of the bar needs. */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
||||||
onClick={() => zoomOut()}
|
onClick={() => zoomOut()}
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
@@ -132,7 +146,7 @@ export function FlowDock({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
||||||
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
|
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
|
||||||
aria-label="Fit the flow to the screen"
|
aria-label="Fit the flow to the screen"
|
||||||
>
|
>
|
||||||
@@ -144,7 +158,7 @@ export function FlowDock({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
||||||
onClick={() => zoomIn()}
|
onClick={() => zoomIn()}
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
@@ -153,7 +167,10 @@ export function FlowDock({
|
|||||||
|
|
||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -189,7 +206,10 @@ export function FlowDock({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
<LogsPanel flow={flow} {...logs} />
|
<LogsPanel flow={flow} {...logs} />
|
||||||
|
|
||||||
@@ -263,7 +283,10 @@ export function FlowDock({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -281,21 +304,59 @@ export function FlowDock({
|
|||||||
<TooltipContent>Flow settings</TooltipContent>
|
<TooltipContent>Flow settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Throwing the edit away sits next to putting it live, and only exists
|
||||||
|
while there is something to throw away. */}
|
||||||
|
{hasDraft ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={onDiscard}
|
||||||
|
aria-label="Discard the unpublished changes"
|
||||||
|
data-testid="discard-draft"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Discard the unpublished changes</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Saved state and publish are one control: the glyph never moves, it
|
||||||
|
* simply stops being something you can press once there is nothing left
|
||||||
|
* to put live. A button that appears and disappears moved everything
|
||||||
|
* beside it just as the work was finished.
|
||||||
|
*/}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-11 text-muted-foreground md:size-8"
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={!hasDraft || publishing || saving || !connected}
|
||||||
|
aria-label="Publish this flow"
|
||||||
|
data-testid="publish-flow"
|
||||||
|
>
|
||||||
{!connected ? (
|
{!connected ? (
|
||||||
<WifiOff className="size-3.5" />
|
<WifiOff className="size-3.5" />
|
||||||
) : saving ? (
|
) : saving || publishing ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{!connected
|
{!connected
|
||||||
? "Reconnecting to the engine"
|
? "Reconnecting to the engine"
|
||||||
|
: publishing
|
||||||
|
? "Publishing"
|
||||||
: saving
|
: saving
|
||||||
? "Saving"
|
? "Saving"
|
||||||
: hasDraft
|
: hasDraft
|
||||||
@@ -303,19 +364,6 @@ export function FlowDock({
|
|||||||
: "All changes saved"}
|
: "All changes saved"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{hasDraft ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-11 shrink-0 rounded-full md:h-8"
|
|
||||||
onClick={onPublish}
|
|
||||||
disabled={publishing}
|
|
||||||
data-testid="publish-flow"
|
|
||||||
>
|
|
||||||
{publishing ? "Publishing…" : "Publish"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CanvasTitle } from "./CanvasTitle"
|
import { CanvasTitle } from "./CanvasTitle"
|
||||||
@@ -46,17 +47,12 @@ import { CommandPalette } from "./CommandPalette"
|
|||||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||||
import { EndpointNode } from "./EndpointNode"
|
import { EndpointNode } from "./EndpointNode"
|
||||||
import {
|
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||||
deriveEndpoints,
|
|
||||||
ENDPOINT_TYPE,
|
|
||||||
isEndpointNode,
|
|
||||||
placementsFor,
|
|
||||||
rememberPlacement,
|
|
||||||
} from "./endpoints"
|
|
||||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
||||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||||
import { FlowPanel } from "./FlowPanel"
|
import { FlowPanel } from "./FlowPanel"
|
||||||
import { LiveEdge } from "./LiveEdge"
|
import { LiveEdge } from "./LiveEdge"
|
||||||
|
import { type Direction, layoutGraph } from "./layout"
|
||||||
import { NodePanel } from "./NodePanel"
|
import { NodePanel } from "./NodePanel"
|
||||||
import "./flow.css"
|
import "./flow.css"
|
||||||
import { liveStore, useFlowPaused } from "./liveStore"
|
import { liveStore, useFlowPaused } from "./liveStore"
|
||||||
@@ -119,8 +115,8 @@ const CLIPBOARD_KEY = "fluksio.nodeClipboard"
|
|||||||
* Remember the document as it was before a change.
|
* Remember the document as it was before a change.
|
||||||
*
|
*
|
||||||
* Fields commit on every keystroke, so consecutive edits that leave the same
|
* Fields commit on every keystroke, so consecutive edits that leave the same
|
||||||
* nodes in place fold into the entry already on the stack. Anything carrying
|
* nodes in place fold into the entry already on the stack. Anything that adds
|
||||||
* positions — a drag, a new node — is a finished action and starts its own.
|
* or removes a node is a finished action and starts its own.
|
||||||
*/
|
*/
|
||||||
function record(
|
function record(
|
||||||
history: History,
|
history: History,
|
||||||
@@ -145,11 +141,12 @@ function record(
|
|||||||
history.future = []
|
history.future = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Positions come from the layout, so xyflow's own state only tracks identity. */
|
||||||
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
|
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
|
||||||
return definitions.map((node) => ({
|
return definitions.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: "flow",
|
type: "flow",
|
||||||
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {},
|
data: {},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -174,26 +171,6 @@ function CanvasBackground() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step a new node off any node already sitting at that spot. */
|
|
||||||
function freePosition(
|
|
||||||
nodes: NodeDef_Input[],
|
|
||||||
start: { x: number; y: number },
|
|
||||||
): { x: number; y: number } {
|
|
||||||
const position = { ...start }
|
|
||||||
// Roughly a node's footprint, so a nudged node clears the one below it.
|
|
||||||
const occupied = () =>
|
|
||||||
nodes.some(
|
|
||||||
(node) =>
|
|
||||||
Math.abs((node.position?.x ?? 0) - position.x) < 220 &&
|
|
||||||
Math.abs((node.position?.y ?? 0) - position.y) < 80,
|
|
||||||
)
|
|
||||||
while (occupied()) {
|
|
||||||
position.x += 48
|
|
||||||
position.y += 96
|
|
||||||
}
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A name that does not collide with the nodes already on the canvas. */
|
/** A name that does not collide with the nodes already on the canvas. */
|
||||||
function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
|
function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
|
||||||
const taken = new Set(existing.map((node) => node.id))
|
const taken = new Set(existing.map((node) => node.id))
|
||||||
@@ -213,7 +190,7 @@ function FlowEditorInner({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showErrorToast, showSuccessToast } = useCustomToast()
|
const { showErrorToast, showSuccessToast } = useCustomToast()
|
||||||
const { screenToFlowPosition, fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
|
|
||||||
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
||||||
@@ -243,6 +220,9 @@ function FlowEditorInner({
|
|||||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||||
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
||||||
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
||||||
|
// Throwing an edit away is offered from the dock, so its confirmation lives
|
||||||
|
// here rather than inside the settings panel.
|
||||||
|
const [discardOpen, setDiscardOpen] = useState(false)
|
||||||
// The editor at full size takes the width the canvas chrome does not need.
|
// The editor at full size takes the width the canvas chrome does not need.
|
||||||
const [editorExpanded, setEditorExpanded] = useState(false)
|
const [editorExpanded, setEditorExpanded] = useState(false)
|
||||||
// The dock hosts the logs, but a failing node opens them too, at its own
|
// The dock hosts the logs, but a failing node opens them too, at its own
|
||||||
@@ -278,14 +258,10 @@ function FlowEditorInner({
|
|||||||
|
|
||||||
/** The same, for the changes that only touch the nodes. */
|
/** The same, for the changes that only touch the nodes. */
|
||||||
const commit = useCallback(
|
const commit = useCallback(
|
||||||
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
|
(nodes: NodeDef_Input[]) => {
|
||||||
const placed = nodes.map((node) => {
|
commitDoc({ ...latest.current, nodes })
|
||||||
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
|
|
||||||
return canvas ? { ...node, position: canvas.position } : node
|
|
||||||
})
|
|
||||||
commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
|
|
||||||
},
|
},
|
||||||
[canvasNodes, commitDoc],
|
[commitDoc],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -383,8 +359,8 @@ function FlowEditorInner({
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
// A cheap fingerprint of the wiring: it changes when a name does, but not
|
// A cheap fingerprint of the wiring, and the only thing the layout depends
|
||||||
// when a node merely moves.
|
// on: what the graph looks like follows from what is wired to what.
|
||||||
const key = bindingsKey(definitions)
|
const key = bindingsKey(definitions)
|
||||||
|
|
||||||
// Offer the names already in play: everything published is worth reading,
|
// Offer the names already in play: everything published is worth reading,
|
||||||
@@ -407,31 +383,6 @@ function FlowEditorInner({
|
|||||||
}
|
}
|
||||||
}, [key])
|
}, [key])
|
||||||
|
|
||||||
// Endpoints are movable but are not the flow's to store, so where they were
|
|
||||||
// put lives in the browser rather than in flow.json.
|
|
||||||
const [moved, setMoved] = useState<Record<string, { x: number; y: number }>>(
|
|
||||||
() => placementsFor(flowName),
|
|
||||||
)
|
|
||||||
|
|
||||||
// React Flow measures a node once and keeps the size on it. Endpoints are
|
|
||||||
// rebuilt on every drag frame, so unless the measurement is carried over
|
|
||||||
// they arrive unmeasured and React Flow drops the edges attached to them
|
|
||||||
// until it has measured again — remounting those edges, which makes them
|
|
||||||
// pulse as if a value had just landed. Their own drag lit up the canvas.
|
|
||||||
const measured = useRef(new Map<string, { width: number; height: number }>())
|
|
||||||
const trackMeasured = useCallback(
|
|
||||||
(changes: NodeChange<FlowCanvasNode>[]) => {
|
|
||||||
for (const change of changes) {
|
|
||||||
if (change.type !== "dimensions" || !change.dimensions) continue
|
|
||||||
if (isEndpointNode({ id: change.id })) {
|
|
||||||
measured.current.set(change.id, change.dimensions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onNodesChange(changes)
|
|
||||||
},
|
|
||||||
[onNodesChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Where clicking an endpoint takes you: the thing it stands for. */
|
/** Where clicking an endpoint takes you: the thing it stands for. */
|
||||||
const openEndpoint = useCallback(
|
const openEndpoint = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
@@ -451,18 +402,31 @@ function FlowEditorInner({
|
|||||||
[navigate],
|
[navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// React Flow measures a node once and keeps the size on it. An endpoint is
|
||||||
|
// not in `canvasNodes`, so the measurement it reports back has nowhere to
|
||||||
|
// land: without carrying it over by hand the endpoint arrives unmeasured on
|
||||||
|
// the next render, and React Flow draws an unmeasured node hidden, taking
|
||||||
|
// the edges attached to it with it.
|
||||||
|
const measured = useRef(new Map<string, { width: number; height: number }>())
|
||||||
|
const trackMeasured = useCallback(
|
||||||
|
(changes: NodeChange<FlowCanvasNode>[]) => {
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.type !== "dimensions" || !change.dimensions) continue
|
||||||
|
if (isEndpointNode({ id: change.id })) {
|
||||||
|
measured.current.set(change.id, change.dimensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodesChange(changes)
|
||||||
|
},
|
||||||
|
[onNodesChange],
|
||||||
|
)
|
||||||
|
|
||||||
// Dashboards and other flows wired into this one. They are drawn but never
|
// Dashboards and other flows wired into this one. They are drawn but never
|
||||||
// stored: they join at render, after everything that reads or writes
|
// stored: they join at render, after everything that reads or writes
|
||||||
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
|
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on.
|
||||||
const external = useMemo(() => {
|
const external = useMemo(() => {
|
||||||
const built = deriveEndpoints(
|
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName)
|
||||||
detail.endpoints ?? [],
|
|
||||||
definitions,
|
|
||||||
flowName,
|
|
||||||
new Map(canvasNodes.map((node) => [node.id, node.position])),
|
|
||||||
moved,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
...built,
|
...built,
|
||||||
nodes: built.nodes.map((node) => {
|
nodes: built.nodes.map((node) => {
|
||||||
@@ -470,30 +434,83 @@ function FlowEditorInner({
|
|||||||
return size ? { ...node, measured: size, ...size } : node
|
return size ? { ...node, measured: size, ...size } : node
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
}, [detail.endpoints, key, flowName])
|
||||||
}, [detail.endpoints, key, flowName, moved])
|
|
||||||
|
|
||||||
// Edges follow from the name bindings, so they are derived, never stored.
|
// Edges follow from the name bindings, so they are derived, never stored.
|
||||||
// Kept off `external` deliberately: an endpoint's edges depend on which
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render.
|
||||||
// messages it touches, never on where it sits, so dragging one must not
|
|
||||||
// rebuild the edge array on every frame.
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
|
||||||
const edges = useMemo(
|
const edges = useMemo(
|
||||||
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
||||||
[key, flowName, detail.endpoints],
|
[key, flowName, detail.endpoints],
|
||||||
)
|
)
|
||||||
|
|
||||||
const shownNodes = useMemo(
|
// Which way the graph runs. A phone has height to spare and no width, so it
|
||||||
() => [...renderedNodes, ...external.nodes],
|
// reads top to bottom; everything else reads left to right.
|
||||||
[renderedNodes, external],
|
const direction: Direction = useIsMobile() ? "TB" : "LR"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nobody places a node here — the graph lays itself out, endpoints included,
|
||||||
|
* so a producer lands upstream of what it feeds without a lane of its own.
|
||||||
|
*
|
||||||
|
* Keyed on which nodes exist and how they are wired, never on the node
|
||||||
|
* objects: React Flow writes measurements back through `onNodesChange`, so
|
||||||
|
* their identity changes constantly and the layout would run on every frame.
|
||||||
|
*/
|
||||||
|
const ids = [
|
||||||
|
...canvasNodes.map((node) => node.id),
|
||||||
|
...external.nodes.map((node) => node.id),
|
||||||
|
]
|
||||||
|
const shapeKey = `${direction}|${key}|${ids.join(",")}`
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
|
||||||
|
const positions = useMemo(
|
||||||
|
() => layoutGraph(ids, edges, direction),
|
||||||
|
[shapeKey, edges],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Editing ports adds and removes handles. React Flow measures those once, so
|
/**
|
||||||
// it has to be told, or an edge to a brand-new handle never gets drawn.
|
* The endpoints, placed.
|
||||||
|
*
|
||||||
|
* Memoised rather than mapped at render: React Flow keeps a node's
|
||||||
|
* measurement against the object it measured, and an endpoint is not in
|
||||||
|
* `canvasNodes`, so handing over a fresh one every render would leave it
|
||||||
|
* permanently unmeasured — which React Flow draws as hidden.
|
||||||
|
*/
|
||||||
|
const externalNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
external.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
position: positions.get(node.id) ?? node.position,
|
||||||
|
})),
|
||||||
|
[external, positions],
|
||||||
|
)
|
||||||
|
|
||||||
|
const shownNodes = useMemo(
|
||||||
|
() => [
|
||||||
|
...renderedNodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
position: positions.get(node.id) ?? node.position,
|
||||||
|
})),
|
||||||
|
...externalNodes,
|
||||||
|
],
|
||||||
|
[renderedNodes, externalNodes, positions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Editing ports adds and removes handles, and flipping direction moves them
|
||||||
|
// to the other side. React Flow measures those once, so it has to be told,
|
||||||
|
// or an edge to a brand-new handle never gets drawn.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateNodeInternals(definitions.map((node) => node.id))
|
updateNodeInternals([
|
||||||
}, [key, updateNodeInternals])
|
...definitions.map((node) => node.id),
|
||||||
|
...external.nodes.map((node) => node.id),
|
||||||
|
])
|
||||||
|
}, [key, direction, external, updateNodeInternals])
|
||||||
|
|
||||||
|
// A relayout can put a new node outside the viewport, and turning the graph
|
||||||
|
// on its side moves everything. Both want the whole flow back in view.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
|
||||||
|
useEffect(() => {
|
||||||
|
fitView({ ...FIT_VIEW, duration: 300 })
|
||||||
|
}, [direction, definitions.length, external.nodes.length, fitView])
|
||||||
|
|
||||||
const runMutation = useMutation({
|
const runMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -565,18 +582,9 @@ function FlowEditorInner({
|
|||||||
const addNode = useCallback(
|
const addNode = useCallback(
|
||||||
(type: string, sourceRef?: string) => {
|
(type: string, sourceRef?: string) => {
|
||||||
const id = uniqueNodeId(definitions, sourceRef ?? type)
|
const id = uniqueNodeId(definitions, sourceRef ?? type)
|
||||||
// Drop it where the user is looking, but never on top of another node.
|
|
||||||
const position = freePosition(
|
|
||||||
definitions,
|
|
||||||
screenToFlowPosition({
|
|
||||||
x: window.innerWidth / 2,
|
|
||||||
y: window.innerHeight / 2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const node: NodeDef_Input = {
|
const node: NodeDef_Input = {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
position,
|
|
||||||
params: {},
|
params: {},
|
||||||
requires: [],
|
requires: [],
|
||||||
provides: [],
|
provides: [],
|
||||||
@@ -584,16 +592,15 @@ function FlowEditorInner({
|
|||||||
// flow's own.
|
// flow's own.
|
||||||
...(sourceRef ? { source_ref: sourceRef } : {}),
|
...(sourceRef ? { source_ref: sourceRef } : {}),
|
||||||
}
|
}
|
||||||
const nextDefinitions = [...definitions, node]
|
// Unwired, so the layout puts it in a rank of its own until it is bound.
|
||||||
const nextCanvas = [
|
setCanvasNodes([
|
||||||
...canvasNodes,
|
...canvasNodes,
|
||||||
{ id, type: "flow", position, data: {} } as FlowCanvasNode,
|
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
|
||||||
]
|
])
|
||||||
setCanvasNodes(nextCanvas)
|
commit([...definitions, node])
|
||||||
commit(nextDefinitions, nextCanvas)
|
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
},
|
},
|
||||||
[canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes],
|
[canvasNodes, commit, definitions, setCanvasNodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateNode = useCallback(
|
const updateNode = useCallback(
|
||||||
@@ -819,31 +826,22 @@ function FlowEditorInner({
|
|||||||
let pool = definitions
|
let pool = definitions
|
||||||
const pasted: NodeDef_Input[] = []
|
const pasted: NodeDef_Input[] = []
|
||||||
for (const node of clipboard.nodes ?? []) {
|
for (const node of clipboard.nodes ?? []) {
|
||||||
const position = freePosition(pool, {
|
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
|
||||||
// Offset, so a copy of a node in this flow is visibly its own.
|
|
||||||
x: (node.position?.x ?? 0) + 48,
|
|
||||||
y: (node.position?.y ?? 0) + 48,
|
|
||||||
})
|
|
||||||
const copy = { ...node, id: uniqueNodeId(pool, node.id), position }
|
|
||||||
pool = [...pool, copy]
|
pool = [...pool, copy]
|
||||||
pasted.push(copy)
|
pasted.push(copy)
|
||||||
}
|
}
|
||||||
if (!pasted.length) return
|
if (!pasted.length) return
|
||||||
|
|
||||||
const nextCanvas = [
|
setCanvasNodes([
|
||||||
...canvasNodes,
|
...canvasNodes,
|
||||||
...pasted.map(
|
...pasted.map((node) => ({
|
||||||
(node) =>
|
|
||||||
({
|
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: "flow",
|
type: "flow",
|
||||||
position: node.position,
|
position: { x: 0, y: 0 },
|
||||||
data: {},
|
data: {},
|
||||||
}) as FlowCanvasNode,
|
})),
|
||||||
),
|
])
|
||||||
]
|
commit(pool)
|
||||||
setCanvasNodes(nextCanvas)
|
|
||||||
commit(pool, nextCanvas)
|
|
||||||
setSelectedId(pasted[pasted.length - 1].id)
|
setSelectedId(pasted[pasted.length - 1].id)
|
||||||
|
|
||||||
clipboard.nodes.forEach((node, index) => {
|
clipboard.nodes.forEach((node, index) => {
|
||||||
@@ -883,25 +881,6 @@ function FlowEditorInner({
|
|||||||
nodes={shownNodes}
|
nodes={shownNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={trackMeasured}
|
onNodesChange={trackMeasured}
|
||||||
onNodeDrag={(_event, _node, dragged) => {
|
|
||||||
// An endpoint's position is ours, not React Flow's, so it only
|
|
||||||
// follows the pointer if we move it every frame.
|
|
||||||
const endpoints = dragged.filter(isEndpointNode)
|
|
||||||
if (!endpoints.length) return
|
|
||||||
setMoved((current) => {
|
|
||||||
const next = { ...current }
|
|
||||||
for (const node of endpoints) next[node.id] = node.position
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onNodeDragStop={(_event, _node, dragged) => {
|
|
||||||
for (const node of dragged.filter(isEndpointNode)) {
|
|
||||||
// Written once at the end; every frame would be a write per pixel.
|
|
||||||
rememberPlacement(flowName, node.id, node.position)
|
|
||||||
}
|
|
||||||
const own = dragged.filter(isDocumentNode)
|
|
||||||
if (own.length) commit(definitions, mergeDragged(canvasNodes, own))
|
|
||||||
}}
|
|
||||||
onNodesDelete={(deleted) =>
|
onNodesDelete={(deleted) =>
|
||||||
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||||
}
|
}
|
||||||
@@ -943,9 +922,14 @@ function FlowEditorInner({
|
|||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={FIT_VIEW}
|
fitViewOptions={FIT_VIEW}
|
||||||
minZoom={0.25}
|
// Low enough that the fit can always show the whole graph. A phone is
|
||||||
|
// 390px wide and a rank of several nodes is thousands, so a floor of
|
||||||
|
// 0.25 left the fit silently short and the flow running off screen.
|
||||||
|
minZoom={0.1}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
nodeDragThreshold={5}
|
// The graph places itself. Nothing here is arranged by hand, which is
|
||||||
|
// what keeps a flow small enough to read at a glance.
|
||||||
|
nodesDraggable={false}
|
||||||
connectionRadius={30}
|
connectionRadius={30}
|
||||||
connectOnClick
|
connectOnClick
|
||||||
autoPanOnConnect
|
autoPanOnConnect
|
||||||
@@ -987,6 +971,7 @@ function FlowEditorInner({
|
|||||||
setFlowPanelOpen(true)
|
setFlowPanelOpen(true)
|
||||||
}}
|
}}
|
||||||
onPublish={() => void publishFlow()}
|
onPublish={() => void publishFlow()}
|
||||||
|
onDiscard={() => setDiscardOpen(true)}
|
||||||
logs={{
|
logs={{
|
||||||
open: logsOpen,
|
open: logsOpen,
|
||||||
node: logsNode,
|
node: logsNode,
|
||||||
@@ -1038,17 +1023,6 @@ function FlowEditorInner({
|
|||||||
toggling={enableMutation.isPending}
|
toggling={enableMutation.isPending}
|
||||||
onToggleEnabled={(next) => enableMutation.mutate(next)}
|
onToggleEnabled={(next) => enableMutation.mutate(next)}
|
||||||
hasDraft={detail.has_draft ?? false}
|
hasDraft={detail.has_draft ?? false}
|
||||||
discarding={discard.isPending}
|
|
||||||
onDiscardDraft={() => {
|
|
||||||
discard.mutate(undefined, {
|
|
||||||
// The published document replaces what is on the canvas, and the
|
|
||||||
// version counter goes back with it.
|
|
||||||
onSuccess: () => {
|
|
||||||
setFlowPanelOpen(false)
|
|
||||||
onReload()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onClose={() => setFlowPanelOpen(false)}
|
onClose={() => setFlowPanelOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1164,6 +1138,42 @@ function FlowEditorInner({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The canvas goes back to the version the engine is running. What
|
||||||
|
you edited since is dropped, though the flow store's git history
|
||||||
|
keeps it.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={discard.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setDiscardOpen(false)
|
||||||
|
discard.mutate(undefined, {
|
||||||
|
// The published document replaces what is on the canvas, and
|
||||||
|
// the version counter goes back with it.
|
||||||
|
onSuccess: () => {
|
||||||
|
setFlowPanelOpen(false)
|
||||||
|
onReload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
data-testid="confirm-discard-draft"
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Not dismissable: until one version wins, every further save fails, so
|
* Not dismissable: until one version wins, every further save fails, so
|
||||||
* there is nothing useful to go back to.
|
* there is nothing useful to go back to.
|
||||||
@@ -1203,17 +1213,10 @@ function FlowEditorInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeDragged(
|
/**
|
||||||
nodes: FlowCanvasNode[],
|
* Seed xyflow's own node state once. It tracks which nodes exist and which are
|
||||||
dragged: FlowCanvasNode[],
|
* selected; the positions on it are placeholders the layout replaces at render.
|
||||||
): FlowCanvasNode[] {
|
*/
|
||||||
const moved = new Map(dragged.map((node) => [node.id, node.position]))
|
|
||||||
return nodes.map((node) =>
|
|
||||||
moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed xyflow's own node state once; it owns positions while you drag. */
|
|
||||||
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
||||||
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { portOf } from "./deriveEdges"
|
import { portOf } from "./deriveEdges"
|
||||||
import { useNodeEmits, useNodeStatus } from "./liveStore"
|
import { useNodeEmits, useNodeStatus } from "./liveStore"
|
||||||
@@ -69,7 +70,7 @@ export type FlowNodeData = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vertically distribute handles so several ports stay reachable. */
|
/** Spread handles along the node's edge so several ports stay reachable. */
|
||||||
function handleOffset(index: number, total: number): string {
|
function handleOffset(index: number, total: number): string {
|
||||||
if (total <= 1) return "50%"
|
if (total <= 1) return "50%"
|
||||||
const span = 60
|
const span = 60
|
||||||
@@ -85,10 +86,13 @@ function PortHandles({
|
|||||||
type: "source" | "target"
|
type: "source" | "target"
|
||||||
position: Position
|
position: Position
|
||||||
}) {
|
}) {
|
||||||
|
// The ports run across whichever edge they sit on.
|
||||||
|
const along = position === Position.Top || position === Position.Bottom
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{specs.map((spec, index) => {
|
{specs.map((spec, index) => {
|
||||||
const port = portOf(spec)
|
const port = portOf(spec)
|
||||||
|
const offset = handleOffset(index, specs.length)
|
||||||
return (
|
return (
|
||||||
<Handle
|
<Handle
|
||||||
key={`${type}-${port}`}
|
key={`${type}-${port}`}
|
||||||
@@ -99,7 +103,7 @@ function PortHandles({
|
|||||||
"!bg-card !border-muted-foreground/60",
|
"!bg-card !border-muted-foreground/60",
|
||||||
!spec.name && "unbound",
|
!spec.name && "unbound",
|
||||||
)}
|
)}
|
||||||
style={{ top: handleOffset(index, specs.length) }}
|
style={along ? { left: offset } : { top: offset }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -112,6 +116,9 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
data as FlowNodeData
|
data as FlowNodeData
|
||||||
const live = useNodeStatus(`${flow}.${definition.id}`)
|
const live = useNodeStatus(`${flow}.${definition.id}`)
|
||||||
const emits = useNodeEmits(`${flow}.${definition.id}`)
|
const emits = useNodeEmits(`${flow}.${definition.id}`)
|
||||||
|
// The graph runs top to bottom on a phone, so the ports have to face that
|
||||||
|
// way too — see DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
const vertical = useIsMobile()
|
||||||
const Icon =
|
const Icon =
|
||||||
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
|
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
|
||||||
// A connector's own type cannot be in the map above, and a device is
|
// A connector's own type cannot be in the map above, and a device is
|
||||||
@@ -143,7 +150,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
<PortHandles
|
<PortHandles
|
||||||
specs={definition.requires ?? []}
|
specs={definition.requires ?? []}
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={vertical ? Position.Top : Position.Left}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -226,7 +233,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
<PortHandles
|
<PortHandles
|
||||||
specs={definition.provides ?? []}
|
specs={definition.provides ?? []}
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={vertical ? Position.Bottom : Position.Right}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export function FlowPanel({
|
|||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
hasDraft,
|
hasDraft,
|
||||||
discarding,
|
|
||||||
onDiscardDraft,
|
|
||||||
enabled,
|
enabled,
|
||||||
toggling,
|
toggling,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
@@ -37,15 +35,12 @@ export function FlowPanel({
|
|||||||
onChange: (next: FlowDef_Input) => void
|
onChange: (next: FlowDef_Input) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
hasDraft: boolean
|
hasDraft: boolean
|
||||||
discarding: boolean
|
|
||||||
onDiscardDraft: () => void
|
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
toggling: boolean
|
toggling: boolean
|
||||||
onToggleEnabled: (next: boolean) => void
|
onToggleEnabled: (next: boolean) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
const [discardOpen, setDiscardOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -110,18 +105,8 @@ export function FlowPanel({
|
|||||||
<span className={PANEL_SECTION}>Unpublished changes</span>
|
<span className={PANEL_SECTION}>Unpublished changes</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The engine is still running the last published version of this
|
The engine is still running the last published version of this
|
||||||
flow.
|
flow. The bar below publishes it, or throws the edit away.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 justify-self-start"
|
|
||||||
disabled={discarding}
|
|
||||||
onClick={() => setDiscardOpen(true)}
|
|
||||||
data-testid="discard-draft"
|
|
||||||
>
|
|
||||||
{discarding ? "Discarding…" : "Discard changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,34 +141,6 @@ export function FlowPanel({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
The canvas goes back to the version the engine is running. What
|
|
||||||
you edited since is dropped, though the flow store's git history
|
|
||||||
keeps it.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
|
||||||
Keep editing
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setDiscardOpen(false)
|
|
||||||
onDiscardDraft()
|
|
||||||
}}
|
|
||||||
data-testid="confirm-discard-draft"
|
|
||||||
>
|
|
||||||
Discard changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ function PortList({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{specs.map((spec, index) => (
|
{specs.map((spec, index) => (
|
||||||
<div key={`port-${index}`} className="grid gap-1">
|
// The curve reads as its own thing rather than as part of the row
|
||||||
|
// above it, so it gets a little air.
|
||||||
|
<div key={`port-${index}`} className="grid gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MessageNameInput
|
<MessageNameInput
|
||||||
value={spec.name ?? ""}
|
value={spec.name ?? ""}
|
||||||
|
|||||||
@@ -30,69 +30,22 @@ export type EndpointNodeData = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lanes either side of the graph, so a label never lands on a node. */
|
|
||||||
const GAP_X = 120
|
|
||||||
const STACK_Y = 64
|
|
||||||
/** Roughly a node's width; only used to find the right-hand lane. */
|
|
||||||
const NODE_W = 220
|
|
||||||
|
|
||||||
export function isEndpointNode(node: { id: string }): boolean {
|
export function isEndpointNode(node: { id: string }): boolean {
|
||||||
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
|
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the author dragged an endpoint to.
|
* Build the endpoints and wire them to the nodes they touch.
|
||||||
*
|
*
|
||||||
* Not in the flow document — an endpoint is not part of the flow, and writing
|
* They carry no position: the caller lays them out together with the flow's
|
||||||
* a position for one into `flow.json` would be a lie about what it contains.
|
* own nodes (see `layout.ts`), so an endpoint that publishes lands upstream of
|
||||||
* A view preference belongs to the view, so it lives in the browser.
|
* what it feeds and one that reads lands downstream of what feeds it, by the
|
||||||
*/
|
* same rule that orders everything else.
|
||||||
const POSITION_KEY = "fluksio-endpoint-positions"
|
|
||||||
|
|
||||||
type Placements = Record<string, Record<string, { x: number; y: number }>>
|
|
||||||
|
|
||||||
function readPlacements(): Placements {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(POSITION_KEY) ?? "{}") as Placements
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function placementsFor(
|
|
||||||
flow: string,
|
|
||||||
): Record<string, { x: number; y: number }> {
|
|
||||||
return readPlacements()[flow] ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberPlacement(
|
|
||||||
flow: string,
|
|
||||||
id: string,
|
|
||||||
position: { x: number; y: number },
|
|
||||||
): void {
|
|
||||||
const all = readPlacements()
|
|
||||||
all[flow] = { ...(all[flow] ?? {}), [id]: position }
|
|
||||||
try {
|
|
||||||
localStorage.setItem(POSITION_KEY, JSON.stringify(all))
|
|
||||||
} catch {
|
|
||||||
// A full or disabled store just means positions reset; not worth failing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Place the endpoints and wire them to the nodes they touch.
|
|
||||||
*
|
|
||||||
* Positions are computed rather than stored: an endpoint is not part of the
|
|
||||||
* flow, so there is nowhere to keep a position that would not be a lie about
|
|
||||||
* what the document contains. A producer sits left of what it feeds, a
|
|
||||||
* consumer right of what feeds it.
|
|
||||||
*/
|
*/
|
||||||
export function deriveEndpoints(
|
export function deriveEndpoints(
|
||||||
endpoints: Endpoint[],
|
endpoints: Endpoint[],
|
||||||
definitions: NodeDef_Input[],
|
definitions: NodeDef_Input[],
|
||||||
flow: string,
|
flow: string,
|
||||||
positions: Map<string, { x: number; y: number }>,
|
|
||||||
moved: Record<string, { x: number; y: number }> = {},
|
|
||||||
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
|
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
|
||||||
if (endpoints.length === 0) return { nodes: [], edges: [] }
|
if (endpoints.length === 0) return { nodes: [], edges: [] }
|
||||||
|
|
||||||
@@ -118,38 +71,18 @@ export function deriveEndpoints(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A lane either side of the graph. Anchoring each label to the node it
|
|
||||||
// feeds put them on top of the nodes, so they live outside the whole thing
|
|
||||||
// instead: producers to the left of everything, consumers to the right.
|
|
||||||
const placed = [...positions.values()]
|
|
||||||
const bounds = {
|
|
||||||
left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0,
|
|
||||||
right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0,
|
|
||||||
top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: FlowCanvasNode[] = []
|
const nodes: FlowCanvasNode[] = []
|
||||||
const edges: Edge[] = []
|
const edges: Edge[] = []
|
||||||
// How many labels already sit on each side, so they stack instead of overlap.
|
|
||||||
const stacked = { left: 0, right: 0 }
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
const produces = endpoint.provides ?? []
|
const produces = endpoint.provides ?? []
|
||||||
const reads = endpoint.requires ?? []
|
const reads = endpoint.requires ?? []
|
||||||
// A producer belongs upstream of what it feeds; everything else downstream.
|
|
||||||
const side = produces.length > 0 ? "left" : "right"
|
|
||||||
|
|
||||||
const index = stacked[side]
|
|
||||||
stacked[side] += 1
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: endpoint.id,
|
id: endpoint.id,
|
||||||
type: ENDPOINT_TYPE,
|
type: ENDPOINT_TYPE,
|
||||||
position: moved[endpoint.id] ?? {
|
// Filled in by the layout, along with the flow's own nodes.
|
||||||
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
|
position: { x: 0, y: 0 },
|
||||||
y: bounds.top + index * STACK_Y,
|
|
||||||
},
|
|
||||||
// Movable, so a canvas can be arranged; still not the flow's to delete.
|
|
||||||
draggable: true,
|
|
||||||
selectable: true,
|
selectable: true,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import dagre from "@dagrejs/dagre"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the nodes of a flow go.
|
||||||
|
*
|
||||||
|
* Nothing on this canvas is placed by hand: a flow is a graph the editor draws,
|
||||||
|
* not a picture someone arranges. That is the design decision — a canvas nobody
|
||||||
|
* can rearrange is one worth keeping small, which is what "atomic flow" means
|
||||||
|
* here — and it also means a flow document carries no positions to go stale.
|
||||||
|
*
|
||||||
|
* Left to right on a desktop, top to bottom on a phone, which is the direction
|
||||||
|
* each screen has room to grow in.
|
||||||
|
*/
|
||||||
|
export type Direction = "LR" | "TB"
|
||||||
|
|
||||||
|
/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */
|
||||||
|
const NODE_W = 220
|
||||||
|
/** Icon row plus two text lines, as measured. */
|
||||||
|
const NODE_H = 56
|
||||||
|
/**
|
||||||
|
* Room for the live value an edge carries (`LiveEdge`'s chip is
|
||||||
|
* `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around
|
||||||
|
* the chip rather than through it.
|
||||||
|
*/
|
||||||
|
const LABEL_W = 150
|
||||||
|
const LABEL_H = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay the graph out and return each node's top-left corner.
|
||||||
|
*
|
||||||
|
* ponytail: every node is treated as 220×56 rather than measured. Measuring
|
||||||
|
* would feed the result back into the layout and oscillate; if nodes ever grow
|
||||||
|
* past that box, take the sizes from `node.measured` once they have settled.
|
||||||
|
*/
|
||||||
|
export function layoutGraph(
|
||||||
|
ids: string[],
|
||||||
|
edges: { source: string; target: string }[],
|
||||||
|
direction: Direction,
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const graph = new dagre.graphlib.Graph()
|
||||||
|
graph.setDefaultEdgeLabel(() => ({}))
|
||||||
|
graph.setGraph({
|
||||||
|
rankdir: direction,
|
||||||
|
// Along the rank, and between ranks. A left-to-right graph needs the wider
|
||||||
|
// gap between ranks because the nodes themselves are wide.
|
||||||
|
nodesep: 40,
|
||||||
|
ranksep: direction === "LR" ? 110 : 80,
|
||||||
|
marginx: 40,
|
||||||
|
marginy: 40,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insertion order is what makes the result deterministic, so it follows the
|
||||||
|
// document rather than whatever order the edges happen to mention nodes in.
|
||||||
|
for (const id of ids) {
|
||||||
|
graph.setNode(id, { width: NODE_W, height: NODE_H })
|
||||||
|
}
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue
|
||||||
|
graph.setEdge(edge.source, edge.target, {
|
||||||
|
width: LABEL_W,
|
||||||
|
height: LABEL_H,
|
||||||
|
labelpos: "c",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dagre.layout(graph)
|
||||||
|
|
||||||
|
// dagre places centres; React Flow wants top-left corners.
|
||||||
|
return new Map(
|
||||||
|
ids.map((id) => {
|
||||||
|
const node = graph.node(id)
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
node
|
||||||
|
? { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 }
|
||||||
|
: { x: 0, y: 0 },
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -157,7 +157,9 @@ function Failure({ event }: { event: EventRow }) {
|
|||||||
) : (
|
) : (
|
||||||
<span className="size-4 shrink-0" />
|
<span className="size-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 flex-1">
|
{/* A traceback's first line runs long; it wraps rather than widening
|
||||||
|
the card, and the rest is behind the chevron anyway. */}
|
||||||
|
<span className="min-w-0 flex-1 break-words">
|
||||||
<span className="font-mono text-sm">
|
<span className="font-mono text-sm">
|
||||||
{event.node || event.flow || "engine"}
|
{event.node || event.flow || "engine"}
|
||||||
</span>
|
</span>
|
||||||
@@ -289,24 +291,28 @@ export function HealthActivity({ range }: { range: Range }) {
|
|||||||
<span className="min-w-0 flex-1 truncate font-mono">
|
<span className="min-w-0 flex-1 truncate font-mono">
|
||||||
{run.flow}
|
{run.flow}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{/* Five fixed columns do not fit a phone. What caused a run
|
||||||
|
is the one a narrow row can do without — the status, the
|
||||||
|
duration and when it ran are why anyone reads this. */}
|
||||||
|
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||||
{run.source}
|
{run.source}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
run.status === "error"
|
run.status === "error"
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: run.status === "ok"
|
: run.status === "ok"
|
||||||
? "text-status-success"
|
? "text-status-success"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground",
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
{run.status}
|
{run.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-16 text-right text-muted-foreground">
|
<span className="w-16 shrink-0 text-right text-muted-foreground">
|
||||||
{si(run.duration_ms)} ms
|
{si(run.duration_ms)} ms
|
||||||
</span>
|
</span>
|
||||||
<span className="w-16 text-right text-xs text-muted-foreground">
|
<span className="w-16 shrink-0 text-right text-xs text-muted-foreground">
|
||||||
{ago(run.started_at)}
|
{ago(run.started_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,8 +368,10 @@ export function HealthActivity({ range }: { range: Range }) {
|
|||||||
<span className="min-w-0 flex-1 truncate font-mono">
|
<span className="min-w-0 flex-1 truncate font-mono">
|
||||||
{item.node}
|
{item.node}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">{item.reason}</span>
|
<span className="min-w-0 max-w-[40%] truncate text-muted-foreground">
|
||||||
<span className="text-xs text-muted-foreground">
|
{item.reason}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
{ago(item.ts)}
|
{ago(item.ts)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -172,8 +172,10 @@ export function HealthOverview({
|
|||||||
<th className="px-3 pb-2 text-center font-medium">Lag</th>
|
<th className="px-3 pb-2 text-center font-medium">Lag</th>
|
||||||
{/* A bounded share rather than all the slack: the columns
|
{/* A bounded share rather than all the slack: the columns
|
||||||
beside it grow with their own content, so the numbers
|
beside it grow with their own content, so the numbers
|
||||||
spread across the middle instead of huddling on the left. */}
|
spread across the middle instead of huddling on the left.
|
||||||
<th className="w-1/3 min-w-32 pb-2 pl-3 text-right font-medium">
|
Its 128px floor is more than a phone has to spare, and a
|
||||||
|
curve that narrow says nothing, so it goes below `sm`. */}
|
||||||
|
<th className="hidden w-1/3 pb-2 pl-3 text-right font-medium sm:table-cell sm:min-w-32">
|
||||||
Trend
|
Trend
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -181,11 +183,14 @@ export function HealthOverview({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(flows ?? []).map((row: FlowRollup) => (
|
{(flows ?? []).map((row: FlowRollup) => (
|
||||||
<tr key={row.flow} className="border-t border-border">
|
<tr key={row.flow} className="border-t border-border">
|
||||||
<td className="py-2 pr-3">
|
<td className="max-w-0 py-2 pr-3">
|
||||||
|
{/* `max-w-0` is what lets a cell truncate at all: without
|
||||||
|
it the table sizes to the longest name and pushes the
|
||||||
|
page sideways. */}
|
||||||
<Link
|
<Link
|
||||||
to="/flows/$flowName"
|
to="/flows/$flowName"
|
||||||
params={{ flowName: row.flow }}
|
params={{ flowName: row.flow }}
|
||||||
className="font-mono hover:underline"
|
className="block truncate font-mono hover:underline"
|
||||||
>
|
>
|
||||||
{row.flow || "—"}
|
{row.flow || "—"}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -210,7 +215,7 @@ export function HealthOverview({
|
|||||||
</td>
|
</td>
|
||||||
{/* The dot straddles the curve's right edge, so the cell
|
{/* The dot straddles the curve's right edge, so the cell
|
||||||
keeps a little room for the half that hangs out. */}
|
keeps a little room for the half that hangs out. */}
|
||||||
<td className="py-2 pr-2 pl-3 text-right">
|
<td className="hidden py-2 pr-2 pl-3 text-right sm:table-cell">
|
||||||
<Spark counts={row.spark} />
|
<Spark counts={row.spark} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -320,7 +320,10 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-transparent relative flex w-full flex-1 flex-col",
|
// `min-w-0`: a flex item's default `min-width: auto` lets one wide
|
||||||
|
// child widen the whole shell, which is how a page ends up scrolling
|
||||||
|
// sideways on a phone. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
|
"bg-transparent relative flex w-full min-w-0 flex-1 flex-col",
|
||||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-e1 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-e1 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function Layout() {
|
|||||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
|
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
|
||||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 p-6 md:p-8">
|
<main className="flex-1 p-4 sm:p-6 md:p-8">
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ function Dashboard() {
|
|||||||
const running = flows.filter((flow) => flow.enabled ?? true).length
|
const running = flows.filter((flow) => flow.enabled ?? true).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
|
||||||
|
// long name or wide table widens the whole column and the page with it.
|
||||||
|
// Every section here is free to shrink instead. See DESIGN-GUIDELINES.md
|
||||||
|
// → Responsive.
|
||||||
|
<div className="grid gap-6 [&>*]:min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="max-w-sm truncate text-2xl">
|
<h1 className="max-w-sm truncate text-2xl">
|
||||||
Hi, {currentUser?.full_name || currentUser?.email} 👋
|
Hi, {currentUser?.full_name || currentUser?.email} 👋
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import { isLoggedIn } from "@/hooks/useAuth"
|
import { isLoggedIn } from "@/hooks/useAuth"
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a wall panel is pointed at.
|
* What a wall panel is pointed at.
|
||||||
@@ -31,16 +32,27 @@ function PanelView() {
|
|||||||
const { name } = Route.useParams()
|
const { name } = Route.useParams()
|
||||||
useFlowSocket()
|
useFlowSocket()
|
||||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||||
|
// A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||||
|
// its size, which reads as nothing at all. Stack it instead.
|
||||||
|
const stacked = useIsMobile()
|
||||||
|
|
||||||
|
if (!dashboard) return <main className="h-svh w-full" />
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
return (
|
return (
|
||||||
|
<main className="h-svh w-full overflow-y-auto p-4">
|
||||||
|
<DashboardView dashboard={dashboard as Dashboard} stacked />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<main className="h-svh w-full overflow-hidden p-4">
|
<main className="h-svh w-full overflow-hidden p-4">
|
||||||
{dashboard ? (
|
{/* The panel's own surface, scaled to whatever screen it landed on. No
|
||||||
// The panel's own surface, scaled to whatever screen it landed on. No
|
dots: nothing is being arranged here. */}
|
||||||
// dots: nothing is being arranged here.
|
|
||||||
<CanvasSurface dashboard={dashboard as Dashboard}>
|
<CanvasSurface dashboard={dashboard as Dashboard}>
|
||||||
{() => <DashboardView dashboard={dashboard as Dashboard} />}
|
{() => <DashboardView dashboard={dashboard as Dashboard} />}
|
||||||
</CanvasSurface>
|
</CanvasSurface>
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ test("a draft stays off the engine until it is published", async ({ page }) => {
|
|||||||
// Adding a node opens its panel, and the floating chrome steps aside for it.
|
// Adding a node opens its panel, and the floating chrome steps aside for it.
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
await expect(page.getByTestId("publish-flow")).toBeVisible()
|
// The publish glyph never moves; it is enabled only while there is
|
||||||
|
// something to put live.
|
||||||
|
await expect(page.getByTestId("publish-flow")).toBeEnabled()
|
||||||
|
|
||||||
// Nothing of this flow is loaded while it is only a draft.
|
// Nothing of this flow is loaded while it is only a draft.
|
||||||
await page.waitForTimeout(1500)
|
await page.waitForTimeout(1500)
|
||||||
@@ -41,7 +43,11 @@ test("a draft stays off the engine until it is published", async ({ page }) => {
|
|||||||
expect(before.nodes).toHaveLength(0)
|
expect(before.nodes).toHaveLength(0)
|
||||||
|
|
||||||
await page.getByTestId("publish-flow").click()
|
await page.getByTestId("publish-flow").click()
|
||||||
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
// Discard is what marks a draft: it goes when the publish has landed. The
|
||||||
|
// publish glyph disables while the request is in flight too, so waiting on
|
||||||
|
// that would not wait for anything.
|
||||||
|
await expect(page.getByTestId("discard-draft")).toBeHidden()
|
||||||
|
await expect(page.getByTestId("publish-flow")).toBeDisabled()
|
||||||
|
|
||||||
const after = await (await api(page, `/flows/${flowName}/state`)).json()
|
const after = await (await api(page, `/flows/${flowName}/state`)).json()
|
||||||
expect(after.nodes.length).toBeGreaterThan(0)
|
expect(after.nodes.length).toBeGreaterThan(0)
|
||||||
@@ -76,11 +82,11 @@ test("discarding a draft goes back to the published flow", async ({ page }) => {
|
|||||||
await page.goto(`/flows/${flowName}`)
|
await page.goto(`/flows/${flowName}`)
|
||||||
await page.waitForSelector(".react-flow__node")
|
await page.waitForSelector(".react-flow__node")
|
||||||
|
|
||||||
await page.getByTestId("edit-flow").click()
|
|
||||||
await page.getByTestId("discard-draft").click()
|
await page.getByTestId("discard-draft").click()
|
||||||
await page.getByTestId("confirm-discard-draft").click()
|
await page.getByTestId("confirm-discard-draft").click()
|
||||||
|
|
||||||
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
await expect(page.getByTestId("discard-draft")).toBeHidden()
|
||||||
|
await expect(page.getByTestId("publish-flow")).toBeDisabled()
|
||||||
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
||||||
expect(detail.has_draft).toBe(false)
|
expect(detail.has_draft).toBe(false)
|
||||||
expect(detail.definition.title).not.toBe("Theirs")
|
expect(detail.definition.title).not.toBe("Theirs")
|
||||||
|
|||||||
@@ -88,20 +88,12 @@ test("a dashboard control is drawn on the flow it feeds", async ({ page }) => {
|
|||||||
await expect(page.getByText("Lever")).toBeVisible()
|
await expect(page.getByText("Lever")).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("moving a node does not save the dashboard into the flow", async ({
|
test("the dashboard is never stored as a node of the flow", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(`/flows/${flowName}`)
|
await page.goto(`/flows/${flowName}`)
|
||||||
const node = page.locator(".react-flow__node-flow").first()
|
await page.waitForSelector(".react-flow__node")
|
||||||
await node.waitFor()
|
// Long enough for an autosave to have landed if the canvas had queued one.
|
||||||
|
|
||||||
const box = await node.boundingBox()
|
|
||||||
if (!box) throw new Error("the node has no position to drag from")
|
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
||||||
await page.mouse.down()
|
|
||||||
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40)
|
|
||||||
await page.mouse.up()
|
|
||||||
// The autosave is debounced; give it room to land.
|
|
||||||
await page.waitForTimeout(2000)
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
||||||
@@ -114,3 +106,25 @@ test("moving a node does not save the dashboard into the flow", async ({
|
|||||||
"Lever",
|
"Lever",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("a node cannot be dragged: the graph places itself", async ({ page }) => {
|
||||||
|
await page.goto(`/flows/${flowName}`)
|
||||||
|
const node = page.locator(".react-flow__node-flow").first()
|
||||||
|
await node.waitFor()
|
||||||
|
|
||||||
|
// React Flow writes a node's place in the graph as the transform on its
|
||||||
|
// wrapper. The screen position is no good here: dragging the canvas pans
|
||||||
|
// the viewport, which moves every node on screen without moving any of them
|
||||||
|
// in the graph — which is the whole point.
|
||||||
|
const at = () => node.evaluate((el) => (el as HTMLElement).style.transform)
|
||||||
|
const before = await at()
|
||||||
|
|
||||||
|
const box = await node.boundingBox()
|
||||||
|
if (!box) throw new Error("the node was not rendered")
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40)
|
||||||
|
await page.mouse.up()
|
||||||
|
|
||||||
|
expect(await at()).toBe(before)
|
||||||
|
})
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ test("running a flow puts values on its edges", async ({ page }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await page.waitForSelector(".react-flow__edge")
|
// Attached, not visible: the layout puts a two-node chain on one rank, so
|
||||||
|
// the edge between them is a straight horizontal line — correct, and a
|
||||||
|
// zero-height box as far as a visibility check is concerned.
|
||||||
|
await page.waitForSelector(".react-flow__edge", { state: "attached" })
|
||||||
|
|
||||||
await page.getByTestId("run-flow").click()
|
await page.getByTestId("run-flow").click()
|
||||||
await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText(
|
await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -35,13 +35,11 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
{
|
{
|
||||||
id: "sensor",
|
id: "sensor",
|
||||||
type: "python",
|
type: "python",
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
provides: [{ name: "reading", dtype: "float" }],
|
provides: [{ name: "reading", dtype: "float" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "logger",
|
id: "logger",
|
||||||
type: "python",
|
type: "python",
|
||||||
position: { x: 260, y: 0 },
|
|
||||||
requires: [{ name: "reading", dtype: "float" }],
|
requires: [{ name: "reading", dtype: "float" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user