Files
app/frontend/src/components/Flow/endpoints.ts
T
stroblme 6f4030203a Let the canvas endpoints be moved, and lift them on hover
Three things they were missing: they could not be dragged out of the lane
they were placed in, they stayed the same weight whether or not you were
reaching for one, and the label sat right against its connector dot.

Where one has been dragged to is a view preference, not part of the flow —
writing a position for a dashboard widget into flow.json would be a lie
about what the document holds — so it lives in the browser, keyed by flow.

Also folds the dashboard shot into `make verify` and drops the one-off
scripts that had accumulated beside it; the durable coverage is the
Playwright specs, which all pass.
2026-08-16 16:07:07 +02:00

205 lines
6.6 KiB
TypeScript

import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
import type { Endpoint, NodeDef_Input } from "@/client"
import type { FlowEdgeData } from "./deriveEdges"
import { portOf, qualify } from "./deriveEdges"
/**
* Canvas elements for things wired into a flow that are not nodes in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow across a dotted name. Without these the canvas shows a
* value changing with nothing to account for it — and pulses whichever node
* happens to be drawn as a producer, which did nothing.
*
* They are never part of the document: this returns display-only nodes that
* are appended after everything which reads or writes `canvasNodes`, so an
* autosave, an undo or a delete cannot reach them.
*/
/** Marks a canvas node as one of these, for anything that has to skip them. */
export const ENDPOINT_TYPE = "endpoint"
export type EndpointNodeData = {
label: string
kind: string
detail: string
/** Messages it publishes into this flow, and ones it reads out of it. */
provides: string[]
requires: string[]
[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 {
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
}
/**
* Where the author dragged an endpoint to.
*
* Not in the flow document — an endpoint is not part of the flow, and writing
* a position for one into `flow.json` would be a lie about what it contains.
* A view preference belongs to the view, so it lives in the browser.
*/
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(
endpoints: Endpoint[],
definitions: NodeDef_Input[],
flow: string,
positions: Map<string, { x: number; y: number }>,
moved: Record<string, { x: number; y: number }> = {},
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
if (endpoints.length === 0) return { nodes: [], edges: [] }
// Which node consumes or produces each message, so a label can sit beside it.
const consumers = new Map<string, { node: string; port: string }[]>()
const producers = new Map<string, { node: string; port: string }[]>()
for (const node of definitions) {
for (const spec of node.requires ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
consumers.set(message, [
...(consumers.get(message) ?? []),
{ node: node.id, port: portOf(spec) },
])
}
for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
producers.set(message, [
...(producers.get(message) ?? []),
{ node: node.id, port: portOf(spec) },
])
}
}
// 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 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) {
const produces = endpoint.provides ?? []
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({
id: endpoint.id,
type: ENDPOINT_TYPE,
position: moved[endpoint.id] ?? {
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
y: bounds.top + index * STACK_Y,
},
// Movable, so a canvas can be arranged; still not the flow's to delete.
draggable: true,
selectable: true,
deletable: false,
data: {
label: endpoint.label,
kind: endpoint.kind,
detail: endpoint.detail ?? "",
provides: produces,
requires: reads,
} satisfies EndpointNodeData,
})
// An endpoint that publishes feeds every node consuming that message.
for (const message of produces) {
for (const consumer of consumers.get(message) ?? []) {
edges.push({
id: `${endpoint.id}->${consumer.node}:${consumer.port}`,
source: endpoint.id,
sourceHandle: message,
target: consumer.node,
targetHandle: consumer.port,
type: "live",
data: {
message,
flow,
producerId: endpoint.id,
} satisfies FlowEdgeData,
})
}
}
// One that reads is fed by every node producing it.
for (const message of reads) {
for (const producer of producers.get(message) ?? []) {
edges.push({
id: `${producer.node}:${producer.port}->${endpoint.id}`,
source: producer.node,
sourceHandle: producer.port,
target: endpoint.id,
targetHandle: message,
type: "live",
data: {
message,
flow,
producerId: `${flow}.${producer.node}`,
} satisfies FlowEdgeData,
})
}
}
}
return { nodes, edges }
}