mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(flows): canvas / mind-map view (PR 2b of 3 — full editing)
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) <noreply@anthropic.com>
This commit is contained in:
parent
90d18c8dbd
commit
dcdfff938d
@ -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 `<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.
|
||||
* The toggle in `flow-editor-shell.tsx` swaps this in for
|
||||
* `<FlowBuilder>` 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 (
|
||||
<div
|
||||
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
|
||||
? "border-primary ring-1 ring-primary/40"
|
||||
: "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">
|
||||
<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">
|
||||
@ -111,6 +156,40 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
{summary}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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<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
|
||||
// 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"
|
||||
/>
|
||||
<Panel position="bottom-right" className="!bottom-4 !right-4">
|
||||
<CanvasAddNodeButton />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
@ -391,3 +534,72 @@ function NodeEditSheet({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
},
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user