wacrm/src/lib/flows/edges.ts
Arnas Donauskas bc859bcbcd
feat(flows): canvas / mind-map view (PR 1 of 3 — read-only)
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).

PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).

Pieces:
  - New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
    pre-existing flows whose position_x / position_y are all zero —
    flow_nodes already reserved those columns in migration 010
    precisely for this).
  - Shared module src/components/flows/shared.tsx lifts NodeType,
    BuilderNode, NODE_META, summarizeNode, truncate so both views
    render nodes identically.
  - Pure helpers (no React, fully unit-tested):
      src/lib/flows/edges.ts   — derive labeled canvas edges per
                                 outgoing slot (button, list row,
                                 condition branch)
      src/lib/flows/layout.ts  — dagre wrapper + shouldAutoLayout
                                 guard so manually-positioned flows
                                 never get rearranged on open
  - FlowCanvas — client component, custom node renderer matching the
    list view's collapsed-card styling, MiniMap + Controls, attribution
    hidden.
  - FlowEditorShell — owns the Canvas / List toggle, conditionally
    mounts the right view.

21 new unit tests (layout + edges); existing 271 still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:41:37 +02:00

150 lines
5.0 KiB
TypeScript

/**
* Derive canvas edges from the flow's node list.
*
* Edges live INSIDE each node's `config` JSONB (each button row /
* list row / condition branch carries its own `next_node_key`). The
* canvas needs them as a separate `{ source, target, label,
* sourceHandle }` list to render arrows, and the labels need to be
* meaningful — a `send_buttons` node with three buttons isn't useful
* on the canvas if the three outgoing arrows are unlabeled.
*
* Why this lives in lib/flows (not next to flow-canvas.tsx): the
* derivation is pure data manipulation with no React-Flow types in
* it, which makes it (a) trivially unit-testable and (b) reusable by
* the editable canvas (PR 2) without dragging in client-only deps.
*
* `sourceHandle` ids are stable strings the canvas wires up to its
* per-node renderer's outgoing connection points. They match the
* scheme PR 2's drag-to-connect handler will read:
* - `next` for single-outgoing nodes
* - `button:<reply_id>` for send_buttons rows
* - `row:<reply_id>` for send_list rows
* - `true` / `false` for condition branches
*/
import type { BuilderNode } from "@/components/flows/shared";
export interface CanvasEdge {
/** Stable per-edge id — required by React-Flow. */
id: string;
/** node_key of the source node. */
source: string;
/** node_key of the target node. */
target: string;
/** Identifies which outgoing slot on the source node this edge belongs to. */
sourceHandle: string;
/** Human-readable label rendered on the canvas (e.g. "Yes button"). */
label?: string;
}
export function deriveCanvasEdges(nodes: BuilderNode[]): CanvasEdge[] {
const knownKeys = new Set(nodes.map((n) => n.node_key));
const edges: CanvasEdge[] = [];
for (const node of nodes) {
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 && knownKeys.has(next)) {
edges.push({
id: `${node.node_key}--next--${next}`,
source: node.node_key,
target: next,
sourceHandle: "next",
});
}
break;
}
case "condition": {
const trueNext = (cfg as { true_next?: string }).true_next;
const falseNext = (cfg as { false_next?: string }).false_next;
if (trueNext && knownKeys.has(trueNext)) {
edges.push({
id: `${node.node_key}--true--${trueNext}`,
source: node.node_key,
target: trueNext,
sourceHandle: "true",
label: "true",
});
}
if (falseNext && knownKeys.has(falseNext)) {
edges.push({
id: `${node.node_key}--false--${falseNext}`,
source: node.node_key,
target: falseNext,
sourceHandle: "false",
label: "false",
});
}
break;
}
case "send_buttons": {
const buttons = Array.isArray(
(cfg as { buttons?: unknown }).buttons,
)
? ((cfg as { buttons: Array<Record<string, unknown>> }).buttons)
: [];
for (const btn of buttons) {
const replyId =
typeof btn.reply_id === "string" ? btn.reply_id : null;
const next =
typeof btn.next_node_key === "string" ? btn.next_node_key : null;
const title = typeof btn.title === "string" ? btn.title : null;
if (!replyId || !next || !knownKeys.has(next)) continue;
edges.push({
id: `${node.node_key}--button:${replyId}--${next}`,
source: node.node_key,
target: next,
sourceHandle: `button:${replyId}`,
label: title ?? replyId,
});
}
break;
}
case "send_list": {
const sections = Array.isArray(
(cfg as { sections?: unknown }).sections,
)
? ((cfg as { sections: Array<Record<string, unknown>> }).sections)
: [];
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;
const next =
typeof row.next_node_key === "string" ? row.next_node_key : null;
const title = typeof row.title === "string" ? row.title : null;
if (!replyId || !next || !knownKeys.has(next)) continue;
edges.push({
id: `${node.node_key}--row:${replyId}--${next}`,
source: node.node_key,
target: next,
sourceHandle: `row:${replyId}`,
label: title ?? replyId,
});
}
}
break;
}
case "handoff":
case "end":
// Terminal nodes — no outgoing edges.
break;
}
}
return edges;
}