From dcdfff938d489ddb8d5fda80df38116d925df18c Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 29 May 2026 20:32:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(flows):=20canvas=20/=20mind-map=20view=20(?= =?UTF-8?q?PR=202b=20of=203=20=E2=80=94=20full=20editing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the editing-affordance gap from PR 2a. The canvas is now in full parity with the list view for everything that fits in a node graph: connections, deletes, and adding nodes all work on-canvas. Adds: - Per-slot Handle components on the custom node renderer. Single- outgoing types get one handle on the right edge; condition gets `true` / `false` handles; send_buttons and send_list render slot rows inside the card with one handle each, so the user can SEE which button or list row they're wiring. Every non-start node gets a target handle on the left. - Drag-to-connect (`onConnect`). Looks up the source node, dispatches the right config patch via the new `applyEdgeConnection(node, sourceHandle, target)` helper, and routes through updateNodeConfig. Rejects self-loops silently (a button whose target is its own node = infinite reprompt). - Keyboard delete on nodes (Backspace + Delete, both for cross-OS parity). Routes through removeNode, which now ALSO unlinks every inbound reference via the new `unlinkNodeReferences(nodes, key)` helper. No more dangling arrows after a delete — the validator previously flagged these as errors and the user had to chase them down manually. - Edge delete. Selecting an edge + Delete clears just that slot's next_node_key (rather than removing anything), via the same applyEdgeConnection helper passing "" as the target. - Floating "+ Add node" button (React-Flow Panel, bottom-right). Same dropdown as the list view; drops the new node at the visible viewport center via reactFlow.screenToFlowPosition. New pure helpers in src/lib/flows/edges.ts (no React, fully unit- tested — 18 new tests, 319 total passing): - outgoingSlots(node) — list of {id, label} per outgoing slot. - applyEdgeConnection(node, sourceHandle, targetKey) — config patch for a drag-to-connect, or null for unrecognised handles. - unlinkNodeReferences(nodes, deletedKey) — clears every direct + nested reference to deletedKey. Returns nodes-by-identity when no reference matches to avoid needless re-renders. The hook's removeNode is the only consumer of unlinkNodeReferences right now, which means LIST-view deletes also stop leaving dangling refs — strict improvement for both views. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-canvas.tsx | 278 ++++++++++++++++--- src/components/flows/flow-editor-state.tsx | 10 +- src/lib/flows/edges.test.ts | 306 ++++++++++++++++++++- src/lib/flows/edges.ts | 263 ++++++++++++++++++ 4 files changed, 822 insertions(+), 35 deletions(-) diff --git a/src/components/flows/flow-canvas.tsx b/src/components/flows/flow-canvas.tsx index 459721a..02b5022 100644 --- a/src/components/flows/flow-canvas.tsx +++ b/src/components/flows/flow-canvas.tsx @@ -1,45 +1,60 @@ "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: - * - Renders every flow_node as a draggable-looking tile (drag is - * visually plausible but doesn't persist in PR 1 — editing comes - * in PR 2). Pan and zoom work normally. + * What this view does: + * - Renders every flow_node as a draggable tile, pan + zoom + + * minimap. Drag positions persist via the editor context + * (writing on dragStop, not every frame). * - Renders edges between nodes, labeled per slot (button title, - * "true" / "false", list row title) so a branching flow reads as - * a real decision tree. + * "true" / "false", list row title) so a branching flow reads + * 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 - * `position_x` / `position_y` are all zero — without this, every - * existing flow would render as a pile of overlapping tiles at + * `position_x` / `position_y` are all zero (pre-canvas flows + * and brand-new flows) — otherwise everything would pile at * the origin. * - * What it intentionally doesn't do (PR 2 territory): - * - Persist drag positions to the DB - * - Open a side panel on click for node editing - * - Drag-to-connect / add / delete nodes - * - * The toggle in `view-toggle.tsx` swaps this in for `` - * 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. + * The toggle in `flow-editor-shell.tsx` swaps this in for + * `` on the same page. Both views share the same + * `BuilderState` via `useFlowEditor()` — toggling never resets + * unsaved edits, and a drag here updates the same nodes array the + * list view reads. */ import { useCallback, useMemo, useState } from "react"; import { Background, Controls, + Handle, MiniMap, + Panel, + Position, ReactFlow, ReactFlowProvider, + useReactFlow, + type Connection, type Node as RfNode, type Edge as RfEdge, type NodeProps, type OnNodeDrag, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Trash2 } from "lucide-react"; +import { Plus, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -51,13 +66,24 @@ import { SheetHeader, SheetTitle, } 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 { NODE_META, summarizeNode, type BuilderNode, + type NodeType, } from "./shared"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useFlowEditor } from "./flow-editor-state"; import { NodeConfigForm } from "./forms/node-config-form"; @@ -83,15 +109,34 @@ function FlowNodeCard({ data, selected }: NodeProps) { const meta = NODE_META[node.node_type]; const summary = summarizeNode(node); 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 (
+ {hasTarget && ( + + )} +
@@ -111,6 +156,40 @@ function FlowNodeCard({ data, selected }: NodeProps) { {summary}
)} + + {isMultiSlot && ( +
+ {slots.map((slot) => ( +
+ + {slot.label} + + +
+ ))} +
+ )} + + {!isMultiSlot && slots.length === 1 && ( + + )}
); } @@ -177,21 +256,17 @@ export function FlowCanvas() { node: n, 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 - // doesn't expose per-slot handles yet (PR 2 wires those up), and - // React-Flow drops edges whose sourceHandle id doesn't resolve. - // Label still rides along so a branch reads as e.g. "Yes button". + // sourceHandle is now wired up — the FlowNodeCard renders a Handle + // per slot whose id matches the scheme in edges.ts, so React-Flow + // can hang the arrow off the right place on each card. const rfEdges: RfEdge[] = canvasEdges.map((e) => ({ id: e.id, source: e.source, target: e.target, + sourceHandle: e.sourceHandle, label: e.label, labelStyle: { fill: "#cbd5e1", fontSize: 11 }, 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[]) => { + 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 // the form so each keystroke goes through the editor context (which // flips `dirty` and feeds the validator). @@ -265,10 +401,14 @@ export function FlowCanvas() { proOptions={{ hideAttribution: true }} onNodeDragStop={handleNodeDragStop} onNodeClick={handleNodeClick} - // Drag-to-connect + delete-by-keyboard still off — both land - // in PR 2b with per-slot handles + cascading edge cleanup. - nodesConnectable={false} - edgesFocusable={false} + onConnect={handleConnect} + onNodesDelete={handleNodesDelete} + onEdgesDelete={handleEdgesDelete} + // 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} // Lower default min/max zoom than the lib's defaults; the // tiles already truncate their summary at a reasonable @@ -288,6 +428,9 @@ export function FlowCanvas() { maskColor="rgba(15, 23, 42, 0.7)" className="!border !border-slate-700 !bg-slate-900" /> + + + @@ -391,3 +534,72 @@ function NodeEditSheet({ ); } + +// ============================================================ +// 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 ( + + + + Add node + + + {ADD_NODE_TYPES.map((t) => { + const meta = NODE_META[t]; + const Icon = meta.icon; + return ( + handleAdd(t)}> + + {meta.label} + + ); + })} + + + ); +} diff --git a/src/components/flows/flow-editor-state.tsx b/src/components/flows/flow-editor-state.tsx index a66496a..aafff27 100644 --- a/src/components/flows/flow-editor-state.tsx +++ b/src/components/flows/flow-editor-state.tsx @@ -48,6 +48,7 @@ import { validateFlowForActivation, type ValidationIssue, } from "@/lib/flows/validate"; +import { unlinkNodeReferences } from "@/lib/flows/edges"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared"; @@ -427,9 +428,16 @@ export function FlowEditorProvider({ const removeNode = useCallback( (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) => ({ ...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, })); }, diff --git a/src/lib/flows/edges.test.ts b/src/lib/flows/edges.test.ts index 640e7d0..eb059d5 100644 --- a/src/lib/flows/edges.test.ts +++ b/src/lib/flows/edges.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { deriveCanvasEdges } from "./edges"; +import { + applyEdgeConnection, + deriveCanvasEdges, + outgoingSlots, + unlinkNodeReferences, +} from "./edges"; import type { BuilderNode } from "@/components/flows/shared"; function nodes(...ns: BuilderNode[]): BuilderNode[] { @@ -285,3 +290,302 @@ describe("deriveCanvasEdges — id stability", () => { 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]); + }); +}); diff --git a/src/lib/flows/edges.ts b/src/lib/flows/edges.ts index b13ec50..e904d9d 100644 --- a/src/lib/flows/edges.ts +++ b/src/lib/flows/edges.ts @@ -147,3 +147,266 @@ export function deriveCanvasEdges(nodes: BuilderNode[]): CanvasEdge[] { 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> }).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> }).sections) + : []; + const slots: OutgoingSlot[] = []; + for (const section of sections) { + const rows = Array.isArray(section.rows) + ? (section.rows as Array>) + : []; + 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 | 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>; + }).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>; + }).sections + : []; + let matched = false; + const next = sections.map((s) => { + const rows = Array.isArray(s.rows) + ? (s.rows as Array>) + : []; + 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 | 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>; + }).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>; + }).sections + : []; + let dirty = false; + const next = sections.map((s) => { + const rows = Array.isArray(s.rows) + ? (s.rows as Array>) + : []; + 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; + } +} +