mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
commit
62d5d98f06
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user