Merge pull request #160 from ArnasDon/feat/flow-canvas-editable-pt2

feat(flows): canvas / mind-map view, PR 2b of 3 — full editing
This commit is contained in:
Arnas Donauskas 2026-05-29 20:35:04 +02:00 committed by GitHub
commit 62d5d98f06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 822 additions and 35 deletions

View File

@ -1,45 +1,60 @@
"use client"; "use client";
/** /**
* Read-only canvas / mind-map view of a flow. * Canvas / mind-map view of a flow. Editable, in parity with the
* list view for everything except the trigger / header / fallback
* panels (those are list-only — they don't fit visually inside a
* node graph and the user can switch to List for them).
* *
* What it does: * What this view does:
* - Renders every flow_node as a draggable-looking tile (drag is * - Renders every flow_node as a draggable tile, pan + zoom +
* visually plausible but doesn't persist in PR 1 — editing comes * minimap. Drag positions persist via the editor context
* in PR 2). Pan and zoom work normally. * (writing on dragStop, not every frame).
* - Renders edges between nodes, labeled per slot (button title, * - Renders edges between nodes, labeled per slot (button title,
* "true" / "false", list row title) so a branching flow reads as * "true" / "false", list row title) so a branching flow reads
* a real decision tree. * as a real decision tree.
* - Click a node → side-sheet opens with the same per-node form
* the list view uses, plus "Set as entry" / "Delete".
* - Drag from a source handle on one node to a target handle on
* another → wires that slot's `next_node_key`. Per-slot handles
* for multi-outgoing types (condition, send_buttons, send_list)
* so the user picks which branch they're wiring.
* - Backspace / Delete on a selected node → removes it AND clears
* every inbound `next_node_key` reference (no dangling arrows).
* - Delete on a selected edge → clears just that slot.
* - "+ Add node" floating button drops a new node at the visible
* viewport center.
* - Runs dagre auto-layout once on mount for flows whose * - Runs dagre auto-layout once on mount for flows whose
* `position_x` / `position_y` are all zero — without this, every * `position_x` / `position_y` are all zero (pre-canvas flows
* existing flow would render as a pile of overlapping tiles at * and brand-new flows) — otherwise everything would pile at
* the origin. * the origin.
* *
* What it intentionally doesn't do (PR 2 territory): * The toggle in `flow-editor-shell.tsx` swaps this in for
* - Persist drag positions to the DB * `<FlowBuilder>` on the same page. Both views share the same
* - Open a side panel on click for node editing * `BuilderState` via `useFlowEditor()` — toggling never resets
* - Drag-to-connect / add / delete nodes * unsaved edits, and a drag here updates the same nodes array the
* * list view reads.
* The toggle in `view-toggle.tsx` swaps this in for `<FlowBuilder>`
* on the same page, so both views render against the same data shape
* (`FlowNodeRow[]` from `/api/flows/[id]`) — that's the only contract
* that has to stay stable across views.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Background, Background,
Controls, Controls,
Handle,
MiniMap, MiniMap,
Panel,
Position,
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
useReactFlow,
type Connection,
type Node as RfNode, type Node as RfNode,
type Edge as RfEdge, type Edge as RfEdge,
type NodeProps, type NodeProps,
type OnNodeDrag, type OnNodeDrag,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -51,13 +66,24 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { deriveCanvasEdges } from "@/lib/flows/edges"; import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
} from "@/lib/flows/edges";
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout"; import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
import { import {
NODE_META, NODE_META,
summarizeNode, summarizeNode,
type BuilderNode, type BuilderNode,
type NodeType,
} from "./shared"; } from "./shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFlowEditor } from "./flow-editor-state"; import { useFlowEditor } from "./flow-editor-state";
import { NodeConfigForm } from "./forms/node-config-form"; import { NodeConfigForm } from "./forms/node-config-form";
@ -83,15 +109,34 @@ function FlowNodeCard({ data, selected }: NodeProps) {
const meta = NODE_META[node.node_type]; const meta = NODE_META[node.node_type];
const summary = summarizeNode(node); const summary = summarizeNode(node);
const Icon = meta.icon; const Icon = meta.icon;
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the
// common targets).
const hasTarget = node.node_type !== "start";
// Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits
// next to the slot it represents.
const isMultiSlot = slots.length > 1;
return ( return (
<div <div
className={cn( className={cn(
"min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors", "relative min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
selected selected
? "border-primary ring-1 ring-primary/40" ? "border-primary ring-1 ring-primary/40"
: "border-slate-700 hover:border-slate-600", : "border-slate-700 hover:border-slate-600",
)} )}
> >
{hasTarget && (
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
/>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} /> <Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400"> <span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
@ -111,6 +156,40 @@ function FlowNodeCard({ data, selected }: NodeProps) {
{summary} {summary}
</div> </div>
)} )}
{isMultiSlot && (
<div className="mt-2 flex flex-col gap-1 border-t border-slate-800 pt-2">
{slots.map((slot) => (
<div
key={slot.id}
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-slate-300"
>
<span className="truncate" title={slot.label}>
{slot.label}
</span>
<Handle
type="source"
id={slot.id}
position={Position.Right}
// Override default absolute positioning so the handle
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-slate-600 !bg-slate-700"
/>
</div>
))}
</div>
)}
{!isMultiSlot && slots.length === 1 && (
<Handle
type="source"
id={slots[0].id}
position={Position.Right}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
/>
)}
</div> </div>
); );
} }
@ -177,21 +256,17 @@ export function FlowCanvas() {
node: n, node: n,
isEntry: n.node_key === entryNodeId, isEntry: n.node_key === entryNodeId,
}, },
// Drag-to-connect + delete-key still off in PR 2a — those land
// in PR 2b with per-slot handles + cascading edge cleanup.
connectable: false,
deletable: false,
}; };
}); });
// Strip sourceHandle from PR 1's edges — the custom node card // sourceHandle is now wired up — the FlowNodeCard renders a Handle
// doesn't expose per-slot handles yet (PR 2 wires those up), and // per slot whose id matches the scheme in edges.ts, so React-Flow
// React-Flow drops edges whose sourceHandle id doesn't resolve. // can hang the arrow off the right place on each card.
// Label still rides along so a branch reads as e.g. "Yes button".
const rfEdges: RfEdge[] = canvasEdges.map((e) => ({ const rfEdges: RfEdge[] = canvasEdges.map((e) => ({
id: e.id, id: e.id,
source: e.source, source: e.source,
target: e.target, target: e.target,
sourceHandle: e.sourceHandle,
label: e.label, label: e.label,
labelStyle: { fill: "#cbd5e1", fontSize: 11 }, labelStyle: { fill: "#cbd5e1", fontSize: 11 },
labelBgStyle: { fill: "#0f172a" }, labelBgStyle: { fill: "#0f172a" },
@ -224,6 +299,67 @@ export function FlowCanvas() {
[], [],
); );
// Drag-to-connect: React-Flow fires onConnect when the user drops a
// handle drag onto a target handle. We look up the source node,
// compute the right config patch via applyEdgeConnection (matches
// the same slot scheme as deriveCanvasEdges), and dispatch via
// updateNodeConfig. The resulting state change re-derives edges on
// the next render — no need to maintain a separate edge list.
const handleConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle) {
return;
}
const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source,
);
if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own
// node = infinite reprompt). Reject silently — the user can
// still wire one via the per-node dropdown if they really want.
if (connection.source === connection.target) return;
const patch = applyEdgeConnection(
sourceNode,
connection.sourceHandle,
connection.target,
);
if (patch) updateNodeConfig(connection.source, patch);
},
[builderNodes, updateNodeConfig],
);
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
// fires this with the set of deleted-node objects; we route each
// through the editor context's removeNode (which now also unlinks
// inbound references so no dangling arrows survive). Closing the
// side panel on delete keeps the UI honest if the user deleted the
// node currently being edited.
const handleNodesDelete = useCallback(
(deleted: RfNode<NodeData>[]) => {
for (const n of deleted) {
removeNode(n.id);
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
}
},
[removeNode, selectedNodeKey],
);
// Edge delete: clear the source node's slot rather than removing
// anything. Edges are derived from configs, so the only way to
// "delete" one is to null out its underlying next_node_key.
const handleEdgesDelete = useCallback(
(deleted: RfEdge[]) => {
for (const e of deleted) {
if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
if (patch) updateNodeConfig(e.source, patch);
}
},
[builderNodes, updateNodeConfig],
);
// Wrapped mutators that target the currently-selected node — pass to // Wrapped mutators that target the currently-selected node — pass to
// the form so each keystroke goes through the editor context (which // the form so each keystroke goes through the editor context (which
// flips `dirty` and feeds the validator). // flips `dirty` and feeds the validator).
@ -265,10 +401,14 @@ export function FlowCanvas() {
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onNodeDragStop={handleNodeDragStop} onNodeDragStop={handleNodeDragStop}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
// Drag-to-connect + delete-by-keyboard still off — both land onConnect={handleConnect}
// in PR 2b with per-slot handles + cascading edge cleanup. onNodesDelete={handleNodesDelete}
nodesConnectable={false} onEdgesDelete={handleEdgesDelete}
edgesFocusable={false} // Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={["Backspace", "Delete"]}
nodesConnectable={true}
edgesFocusable={true}
elementsSelectable={true} elementsSelectable={true}
// Lower default min/max zoom than the lib's defaults; the // Lower default min/max zoom than the lib's defaults; the
// tiles already truncate their summary at a reasonable // tiles already truncate their summary at a reasonable
@ -288,6 +428,9 @@ export function FlowCanvas() {
maskColor="rgba(15, 23, 42, 0.7)" maskColor="rgba(15, 23, 42, 0.7)"
className="!border !border-slate-700 !bg-slate-900" className="!border !border-slate-700 !bg-slate-900"
/> />
<Panel position="bottom-right" className="!bottom-4 !right-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow> </ReactFlow>
</div> </div>
@ -391,3 +534,72 @@ function NodeEditSheet({
</Sheet> </Sheet>
); );
} }
// ============================================================
// Floating add-node button — bottom-right of the canvas. Mirrors
// the list view's AddNodeButton (same dropdown menu, same NodeType
// list, same icons via NODE_META) but drops the new node into the
// center of the visible viewport rather than appending to a list.
// ============================================================
const ADD_NODE_TYPES: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
];
function CanvasAddNodeButton() {
const reactFlow = useReactFlow();
const { addNode, updateNodePosition } = useFlowEditor();
const handleAdd = (type: NodeType) => {
const key = addNode(type);
// Place the new node at the visible canvas center. The Panel's
// own DOM lives inside ReactFlow so we can climb up to find the
// .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view.
const root = document.querySelector(".react-flow") as HTMLElement | null;
if (!root) return;
const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the
// viewport center.
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 shadow-lg transition-colors hover:bg-slate-800"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
{ADD_NODE_TYPES.map((t) => {
const meta = NODE_META[t];
const Icon = meta.icon;
return (
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -48,6 +48,7 @@ import {
validateFlowForActivation, validateFlowForActivation,
type ValidationIssue, type ValidationIssue,
} from "@/lib/flows/validate"; } from "@/lib/flows/validate";
import { unlinkNodeReferences } from "@/lib/flows/edges";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared"; import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
@ -427,9 +428,16 @@ export function FlowEditorProvider({
const removeNode = useCallback( const removeNode = useCallback(
(key: string) => { (key: string) => {
// Auto-unlink inbound references so canvas / list deletes don't
// leave dangling arrows behind that the validator would flag.
// Cleared refs become "" (the "no target picked" sentinel the
// builder forms already use).
setState((s) => ({ setState((s) => ({
...s, ...s,
nodes: s.nodes.filter((n) => n.node_key !== key), nodes: unlinkNodeReferences(
s.nodes.filter((n) => n.node_key !== key),
key,
),
entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, entry_node_id: s.entry_node_id === key ? null : s.entry_node_id,
})); }));
}, },

View File

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { deriveCanvasEdges } from "./edges"; import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
unlinkNodeReferences,
} from "./edges";
import type { BuilderNode } from "@/components/flows/shared"; import type { BuilderNode } from "@/components/flows/shared";
function nodes(...ns: BuilderNode[]): BuilderNode[] { function nodes(...ns: BuilderNode[]): BuilderNode[] {
@ -285,3 +290,302 @@ describe("deriveCanvasEdges — id stability", () => {
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
}); });
}); });
describe("outgoingSlots", () => {
it("returns a single 'next' slot for the auto-advancing types", () => {
const each = (node: BuilderNode) =>
outgoingSlots(node).map((s) => s.id);
expect(
each({ node_key: "x", node_type: "start", config: { next_node_key: "y" } }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "send_message", config: {} }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "send_media", config: {} }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "collect_input", config: {} }),
).toEqual(["next"]);
expect(each({ node_key: "x", node_type: "set_tag", config: {} })).toEqual([
"next",
]);
});
it("returns true/false slots for condition", () => {
const slots = outgoingSlots({
node_key: "c",
node_type: "condition",
config: {},
});
expect(slots.map((s) => s.id)).toEqual(["true", "false"]);
expect(slots.map((s) => s.label)).toEqual(["true", "false"]);
});
it("returns one slot per button, labelled with the title", () => {
const slots = outgoingSlots({
node_key: "m",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
},
});
expect(slots).toEqual([
{ id: "button:yes", label: "Yes" },
{ id: "button:no", label: "No" },
]);
});
it("falls back to reply_id for buttons with no title", () => {
const slots = outgoingSlots({
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "raw", next_node_key: "" }],
},
});
expect(slots[0].label).toBe("raw");
});
it("flattens list rows across all sections", () => {
const slots = outgoingSlots({
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "View",
sections: [
{ rows: [{ reply_id: "o1", title: "Order 1", next_node_key: "" }] },
{ rows: [{ reply_id: "o2", title: "Order 2", next_node_key: "" }] },
],
},
});
expect(slots.map((s) => s.id)).toEqual(["row:o1", "row:o2"]);
});
it("terminal nodes (handoff / end) have no outgoing slots", () => {
expect(
outgoingSlots({ node_key: "h", node_type: "handoff", config: {} }),
).toEqual([]);
expect(
outgoingSlots({ node_key: "e", node_type: "end", config: {} }),
).toEqual([]);
});
});
describe("applyEdgeConnection", () => {
it("patches next_node_key for single-outgoing nodes", () => {
const node: BuilderNode = {
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "" },
};
expect(applyEdgeConnection(node, "next", "b")).toEqual({
next_node_key: "b",
});
});
it("returns null when the source handle isn't recognised on the type", () => {
const node: BuilderNode = {
node_key: "a",
node_type: "send_message",
config: {},
};
expect(applyEdgeConnection(node, "true", "b")).toBeNull();
expect(applyEdgeConnection(node, "button:x", "b")).toBeNull();
});
it("patches the right branch on a condition", () => {
const node: BuilderNode = {
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "equals",
value: "y",
true_next: "",
false_next: "",
},
};
expect(applyEdgeConnection(node, "true", "t")).toEqual({ true_next: "t" });
expect(applyEdgeConnection(node, "false", "f")).toEqual({
false_next: "f",
});
});
it("patches only the matching button row on send_buttons", () => {
const node: BuilderNode = {
node_key: "m",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
},
};
const patch = applyEdgeConnection(node, "button:yes", "ok");
expect(patch).toEqual({
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "ok" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
});
});
it("returns null when the button reply_id doesn't exist on the node", () => {
const node: BuilderNode = {
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "a", title: "A", next_node_key: "" }],
},
};
expect(applyEdgeConnection(node, "button:ghost", "z")).toBeNull();
});
it("patches the matching list row across sections", () => {
const node: BuilderNode = {
node_key: "l",
node_type: "send_list",
config: {
text: "x",
button_label: "View",
sections: [
{ rows: [{ reply_id: "o1", title: "O1", next_node_key: "" }] },
{ rows: [{ reply_id: "o2", title: "O2", next_node_key: "" }] },
],
},
};
const patch = applyEdgeConnection(node, "row:o2", "tgt") as {
sections: Array<{ rows: Array<{ next_node_key: string }> }>;
};
expect(patch.sections[0].rows[0].next_node_key).toBe("");
expect(patch.sections[1].rows[0].next_node_key).toBe("tgt");
});
it("returns null for terminal nodes (no outgoing)", () => {
expect(
applyEdgeConnection(
{ node_key: "h", node_type: "handoff", config: {} },
"next",
"x",
),
).toBeNull();
expect(
applyEdgeConnection(
{ node_key: "e", node_type: "end", config: {} },
"next",
"x",
),
).toBeNull();
});
});
describe("unlinkNodeReferences", () => {
it("clears next_node_key when it points at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "victim" },
},
{ node_key: "victim", node_type: "end", config: {} },
];
const after = unlinkNodeReferences(before, "victim");
expect(
(after[0].config as { next_node_key: string }).next_node_key,
).toBe("");
});
it("clears both true_next and false_next when condition points at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "c",
node_type: "condition",
config: {
true_next: "victim",
false_next: "victim",
},
},
];
const after = unlinkNodeReferences(before, "victim");
const cfg = after[0].config as {
true_next: string;
false_next: string;
};
expect(cfg.true_next).toBe("");
expect(cfg.false_next).toBe("");
});
it("clears only the buttons that point at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "victim" },
{ reply_id: "b", title: "B", next_node_key: "safe" },
],
},
},
];
const after = unlinkNodeReferences(before, "victim");
const buttons = (after[0].config as {
buttons: Array<{ reply_id: string; next_node_key: string }>;
}).buttons;
expect(buttons[0].next_node_key).toBe("");
expect(buttons[1].next_node_key).toBe("safe");
});
it("clears only the list rows that point at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "l",
node_type: "send_list",
config: {
sections: [
{
rows: [
{ reply_id: "r1", next_node_key: "victim" },
{ reply_id: "r2", next_node_key: "safe" },
],
},
],
},
},
];
const after = unlinkNodeReferences(before, "victim");
const rows = (after[0].config as {
sections: Array<{ rows: Array<{ next_node_key: string }> }>;
}).sections[0].rows;
expect(rows[0].next_node_key).toBe("");
expect(rows[1].next_node_key).toBe("safe");
});
it("returns the input nodes by identity when none reference the deleted key (no-op path)", () => {
const nodes: BuilderNode[] = [
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "b" },
},
{ node_key: "b", node_type: "end", config: {} },
];
const after = unlinkNodeReferences(nodes, "ghost");
// Same array length, each entry === input (no clone).
expect(after).toHaveLength(2);
expect(after[0]).toBe(nodes[0]);
expect(after[1]).toBe(nodes[1]);
});
});

View File

@ -147,3 +147,266 @@ export function deriveCanvasEdges(nodes: BuilderNode[]): CanvasEdge[] {
return edges; return edges;
} }
// ============================================================
// Inverse operations — used by the canvas's drag-to-connect and
// delete-with-cleanup handlers (PR 2b). Kept in lib/flows so the
// canvas component stays free of edge-bookkeeping logic.
// ============================================================
/**
* Outgoing-slot list for a node — used by the canvas to render one
* source-side Handle per slot, labelled with the slot's user-facing
* name. Order follows the order the slots appear in the node's
* config so visual layout matches the form layout.
*
* Terminal nodes (handoff / end) return an empty list — they have
* no outgoing edges and no source handles.
*/
export interface OutgoingSlot {
/** Stable id matching the `sourceHandle` scheme used in
* CanvasEdge. */
id: string;
/** Visible label rendered next to the handle. */
label: string;
}
export function outgoingSlots(node: BuilderNode): OutgoingSlot[] {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag":
return [{ id: "next", label: "Next" }];
case "condition":
return [
{ id: "true", label: "true" },
{ id: "false", label: "false" },
];
case "send_buttons": {
const buttons = Array.isArray((cfg as { buttons?: unknown }).buttons)
? ((cfg as { buttons: Array<Record<string, unknown>> }).buttons)
: [];
return buttons
.filter((b) => typeof b.reply_id === "string" && b.reply_id)
.map((b) => {
const replyId = b.reply_id as string;
const title = typeof b.title === "string" ? b.title : null;
return {
id: `button:${replyId}`,
label: title ?? replyId,
};
});
}
case "send_list": {
const sections = Array.isArray((cfg as { sections?: unknown }).sections)
? ((cfg as { sections: Array<Record<string, unknown>> }).sections)
: [];
const slots: OutgoingSlot[] = [];
for (const section of sections) {
const rows = Array.isArray(section.rows)
? (section.rows as Array<Record<string, unknown>>)
: [];
for (const row of rows) {
const replyId =
typeof row.reply_id === "string" ? row.reply_id : null;
if (!replyId) continue;
const title = typeof row.title === "string" ? row.title : null;
slots.push({
id: `row:${replyId}`,
label: title ?? replyId,
});
}
}
return slots;
}
case "handoff":
case "end":
return [];
}
}
/**
* Compute the config patch to apply when the user drags an edge from
* `sourceHandle` on a node to `targetKey`. Returns `null` when the
* handle isn't recognised on the node type (defensive — React-Flow
* would have to misroute for this to fire).
*
* For `send_buttons` and `send_list`, only the button/row with the
* matching reply_id is patched; the rest of the array passes through
* unchanged.
*/
export function applyEdgeConnection(
node: BuilderNode,
sourceHandle: string,
targetKey: string,
): Record<string, unknown> | null {
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag":
if (sourceHandle === "next") return { next_node_key: targetKey };
return null;
case "condition":
if (sourceHandle === "true") return { true_next: targetKey };
if (sourceHandle === "false") return { false_next: targetKey };
return null;
case "send_buttons": {
if (!sourceHandle.startsWith("button:")) return null;
const replyId = sourceHandle.slice("button:".length);
const buttons = Array.isArray(
(node.config as { buttons?: unknown }).buttons,
)
? (node.config as {
buttons: Array<Record<string, unknown>>;
}).buttons
: [];
// No matching button → no-op (caller should have surfaced a
// missing slot before letting the user drag).
if (!buttons.some((b) => b.reply_id === replyId)) return null;
return {
buttons: buttons.map((b) =>
b.reply_id === replyId ? { ...b, next_node_key: targetKey } : b,
),
};
}
case "send_list": {
if (!sourceHandle.startsWith("row:")) return null;
const replyId = sourceHandle.slice("row:".length);
const sections = Array.isArray(
(node.config as { sections?: unknown }).sections,
)
? (node.config as {
sections: Array<Record<string, unknown>>;
}).sections
: [];
let matched = false;
const next = sections.map((s) => {
const rows = Array.isArray(s.rows)
? (s.rows as Array<Record<string, unknown>>)
: [];
return {
...s,
rows: rows.map((r) => {
if (r.reply_id === replyId) {
matched = true;
return { ...r, next_node_key: targetKey };
}
return r;
}),
};
});
return matched ? { sections: next } : null;
}
case "handoff":
case "end":
return null;
}
}
/**
* Walk every node and clear any `next_node_key` / `true_next` /
* `false_next` / `button.next_node_key` / `row.next_node_key`
* reference to `deletedKey`. Cleared refs become the empty string —
* the same "no target picked" sentinel the builder forms use.
*
* Returns a new array; original nodes are left untouched. Nodes
* without any matching reference pass through by identity to avoid
* needless re-renders downstream.
*/
export function unlinkNodeReferences(
nodes: BuilderNode[],
deletedKey: string,
): BuilderNode[] {
return nodes.map((n) => {
const patched = patchedConfigWithoutKey(n, deletedKey);
return patched ? { ...n, config: patched } : n;
});
}
function patchedConfigWithoutKey(
node: BuilderNode,
deletedKey: string,
): Record<string, unknown> | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag": {
const next = (cfg as { next_node_key?: string }).next_node_key;
if (next !== deletedKey) return null;
return { ...cfg, next_node_key: "" };
}
case "condition": {
const c = cfg as { true_next?: string; false_next?: string };
const trueMatch = c.true_next === deletedKey;
const falseMatch = c.false_next === deletedKey;
if (!trueMatch && !falseMatch) return null;
return {
...cfg,
...(trueMatch ? { true_next: "" } : {}),
...(falseMatch ? { false_next: "" } : {}),
};
}
case "send_buttons": {
const buttons = Array.isArray((cfg as { buttons?: unknown }).buttons)
? (cfg as {
buttons: Array<Record<string, unknown>>;
}).buttons
: [];
if (!buttons.some((b) => b.next_node_key === deletedKey)) return null;
return {
...cfg,
buttons: buttons.map((b) =>
b.next_node_key === deletedKey ? { ...b, next_node_key: "" } : b,
),
};
}
case "send_list": {
const sections = Array.isArray((cfg as { sections?: unknown }).sections)
? (cfg as {
sections: Array<Record<string, unknown>>;
}).sections
: [];
let dirty = false;
const next = sections.map((s) => {
const rows = Array.isArray(s.rows)
? (s.rows as Array<Record<string, unknown>>)
: [];
return {
...s,
rows: rows.map((r) => {
if (r.next_node_key === deletedKey) {
dirty = true;
return { ...r, next_node_key: "" };
}
return r;
}),
};
});
return dirty ? { ...cfg, sections: next } : null;
}
case "handoff":
case "end":
return null;
}
}