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>
This commit is contained in:
Arnas Donauskas 2026-05-29 19:41:37 +02:00
parent cd5dea1e4e
commit bc859bcbcd
No known key found for this signature in database
11 changed files with 1533 additions and 202 deletions

237
package-lock.json generated
View File

@ -10,11 +10,13 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.10.3", "@supabase/ssr": "^0.10.3",
"@supabase/supabase-js": "^2.106.2", "@supabase/supabase-js": "^2.106.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.3.0", "date-fns": "^4.3.0",
@ -531,6 +533,21 @@
} }
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -2874,6 +2891,55 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -3621,6 +3687,38 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@ -4239,6 +4337,12 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@ -4524,6 +4628,111 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -10910,6 +11119,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }

View File

@ -40,11 +40,13 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.10.3", "@supabase/ssr": "^0.10.3",
"@supabase/supabase-js": "^2.106.2", "@supabase/supabase-js": "^2.106.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.3.0", "date-fns": "^4.3.0",

View File

@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FlowBuilder } from "@/components/flows/flow-builder"; import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
/** /**
@ -84,5 +84,5 @@ export default function FlowEditorPage() {
); );
} }
return <FlowBuilder initialFlow={flow} initialNodes={nodes} />; return <FlowEditorShell initialFlow={flow} initialNodes={nodes} />;
} }

View File

@ -26,21 +26,13 @@ import {
Plus, Plus,
Save, Save,
Trash2, Trash2,
Workflow,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
MessageCircle,
ListChecks,
ListPlus,
CornerDownRight, CornerDownRight,
UserPlus,
Flag,
PlayCircle,
PauseCircle, PauseCircle,
Inbox, PlayCircle,
GitFork,
Tag,
Paperclip, Paperclip,
Workflow,
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -70,6 +62,12 @@ import {
} from "@/lib/flows/validate"; } from "@/lib/flows/validate";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import {
NODE_META,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
interface FlowBuilderProps { interface FlowBuilderProps {
initialFlow: FlowRow; initialFlow: FlowRow;
@ -82,24 +80,6 @@ interface FlowBuilderProps {
// different shape. The sub-form components narrow as needed. // different shape. The sub-form components narrow as needed.
// ============================================================ // ============================================================
type NodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "send_media"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
interface BuilderNode {
node_key: string;
node_type: NodeType;
config: Record<string, unknown>;
}
interface BuilderState { interface BuilderState {
name: string; name: string;
description: string; description: string;
@ -110,59 +90,6 @@ interface BuilderState {
nodes: BuilderNode[]; nodes: BuilderNode[];
} }
// ============================================================
// Per-node-type metadata used to render icons + labels everywhere
// the user sees a node summary.
// ============================================================
const NODE_META: Record<
NodeType,
{ label: string; icon: typeof Workflow; color: string }
> = {
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
send_message: {
label: "Send message",
icon: MessageCircle,
color: "text-sky-400",
},
send_buttons: {
label: "Send buttons",
icon: ListChecks,
color: "text-primary",
},
send_list: {
label: "Send list",
icon: ListPlus,
color: "text-indigo-400",
},
send_media: {
label: "Send media",
icon: Paperclip,
color: "text-cyan-400",
},
collect_input: {
label: "Collect input",
icon: Inbox,
color: "text-teal-400",
},
condition: {
label: "If / else",
icon: GitFork,
color: "text-fuchsia-400",
},
set_tag: {
label: "Tag contact",
icon: Tag,
color: "text-pink-400",
},
handoff: {
label: "Handoff to agent",
icon: UserPlus,
color: "text-amber-400",
},
end: { label: "End", icon: Flag, color: "text-slate-400" },
};
// ============================================================ // ============================================================
// Helpers // Helpers
// ============================================================ // ============================================================
@ -183,125 +110,6 @@ function uniqueNodeKey(base: string, existing: BuilderNode[]): string {
return `${base}_${i}`; return `${base}_${i}`;
} }
// Short, single-line content summary used in the collapsed NodeCard
// header — lets users scan a 10-node flow without expanding every card.
// Returns null when there's nothing meaningful to show (start/end, or
// a freshly-added node with no fields filled in).
function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, " ").trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + "…";
}
function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "end":
return null;
case "send_message": {
const text = typeof cfg.text === "string" ? cfg.text : "";
return text.length > 0 ? truncate(text) : null;
}
case "send_buttons": {
const text = typeof cfg.text === "string" ? cfg.text : "";
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : ""))
.filter(Boolean)
.join(" / ");
if (text.length > 0) {
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text);
}
return titles || null;
}
case "send_list": {
const text = typeof cfg.text === "string" ? cfg.text : "";
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
const rowCount = sections.reduce<number>((sum, s) => {
const rows = Array.isArray(s.rows) ? s.rows : [];
return sum + rows.length;
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
: truncate(text);
}
return rowCount > 0
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}`
: null;
}
case "send_media": {
const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : "";
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media";
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file";
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case "collect_input": {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
if (prompt.length > 0) {
return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case "condition": {
const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
if (!subjectKey) return null;
const subject =
cfg.subject === "tag"
? "tag"
: cfg.subject === "contact_field"
? "field"
: "var";
const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
const op =
cfg.operator === "equals"
? "=="
: cfg.operator === "contains"
? "contains"
: cfg.operator === "present"
? "exists"
: cfg.operator === "absent"
? "missing"
: "";
const value = typeof cfg.value === "string" ? cfg.value : "";
const valStr =
(cfg.operator === "equals" || cfg.operator === "contains") && value
? ` "${truncate(value, 20)}"`
: "";
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case "set_tag": {
const mode = cfg.mode === "remove" ? "Remove" : "Add";
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : "";
// No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance.
return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`;
}
case "handoff": {
const note = typeof cfg.note === "string" ? cfg.note : "";
return note.length > 0 ? truncate(note) : null;
}
}
}
function defaultConfigFor(type: NodeType): Record<string, unknown> { function defaultConfigFor(type: NodeType): Record<string, unknown> {
switch (type) { switch (type) {
case "start": case "start":

View File

@ -0,0 +1,233 @@
"use client";
/**
* Read-only canvas / mind-map view of a flow.
*
* 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.
* - Renders edges between nodes, labeled per slot (button title,
* "true" / "false", list row title) so a branching flow reads as
* a real decision tree.
* - 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
* 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.
*/
import { useMemo } from "react";
import {
Background,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
type Node as RfNode,
type Edge as RfEdge,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { cn } from "@/lib/utils";
import { deriveCanvasEdges } from "@/lib/flows/edges";
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
import {
NODE_META,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
interface FlowCanvasProps {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
}
// React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> {
node: BuilderNode;
isEntry: boolean;
}
const NODE_WIDTH = 240;
// Best-effort default; actual height varies by summary length but
// dagre needs SOMETHING to compute rank spacing. Underestimating is
// safer than over (tighter layout that still doesn't overlap).
const NODE_HEIGHT = 90;
// ============================================================
// Custom node — one card per flow node, styled to match the list
// view's collapsed card so the two views feel like the same product.
// ============================================================
function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry } = data as NodeData;
const meta = NODE_META[node.node_type];
const summary = summarizeNode(node);
const Icon = meta.icon;
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",
selected
? "border-primary ring-1 ring-primary/40"
: "border-slate-700 hover:border-slate-600",
)}
>
<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">
{meta.label}
</span>
{isEntry && (
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
Entry
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-slate-300">
{node.node_key}
</div>
{summary && (
<div className="mt-1 line-clamp-2 text-xs text-slate-400">
{summary}
</div>
)}
</div>
);
}
const NODE_TYPES = { flow: FlowNodeCard };
// ============================================================
// Root canvas
// ============================================================
export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
const { rfNodes, rfEdges } = useMemo(() => {
const builderNodes: BuilderNode[] = initialNodes.map((n) => ({
node_key: n.node_key,
node_type: n.node_type as NodeType,
config: n.config as Record<string, unknown>,
position_x: n.position_x,
position_y: n.position_y,
}));
const canvasEdges = deriveCanvasEdges(builderNodes);
// Decide whether to auto-layout. The helper guards against
// overwriting a user's manual arrangement (only fires when ALL
// nodes sit at the origin), so we can safely call it
// unconditionally — if any node has been positioned, this is a
// no-op.
const positions = shouldAutoLayout(builderNodes)
? autoLayout(
builderNodes.map((n) => ({
id: n.node_key,
width: NODE_WIDTH,
height: NODE_HEIGHT,
})),
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "TB" },
)
: null;
const rfNodes: RfNode<NodeData>[] = builderNodes.map((n) => {
const fallback = positions?.get(n.node_key);
return {
id: n.node_key,
type: "flow",
position: {
x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0,
},
data: {
node: n,
isEntry: n.node_key === initialFlow.entry_node_id,
},
// Read-only in PR 1 — block all editing affordances. Drag is
// still allowed visually because disabling it makes the canvas
// feel inert in a way that's worse than letting users nudge a
// tile that won't save.
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".
const rfEdges: RfEdge[] = canvasEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
labelStyle: { fill: "#cbd5e1", fontSize: 11 },
labelBgStyle: { fill: "#0f172a" },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "#475569", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [initialFlow.entry_node_id, initialNodes]);
if (rfNodes.length === 0) {
return (
<div className="flex h-[60vh] items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
No nodes yet. Switch to List view to add your first node.
</div>
);
}
return (
<ReactFlowProvider>
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
proOptions={{ hideAttribution: true }}
// Read-only knobs — keeps PR 1's surface inert at the
// React-Flow level. PR 2 will flip these back on.
nodesConnectable={false}
edgesFocusable={false}
elementsSelectable={true}
// Lower default min/max zoom than the lib's defaults; the
// tiles already truncate their summary at a reasonable
// size, so we don't need to zoom past 1.5x.
minZoom={0.2}
maxZoom={1.5}
>
<Background gap={24} size={1} color="#1e293b" />
<Controls
className="!border-slate-700 !bg-slate-900 [&_button]:!border-slate-700 [&_button]:!bg-slate-900 [&_button:hover]:!bg-slate-800"
showInteractive={false}
/>
<MiniMap
pannable
zoomable
nodeColor="#334155"
maskColor="rgba(15, 23, 42, 0.7)"
className="!border !border-slate-700 !bg-slate-900"
/>
</ReactFlow>
</div>
</ReactFlowProvider>
);
}

View File

@ -0,0 +1,120 @@
"use client";
/**
* View-switcher for the flow editor.
*
* Renders a small Canvas / List pill above whichever view is active,
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
* separate component:
* - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else.
*
* View choice persists per-browser via localStorage so a power user
* who prefers the list isn't fighting the default on every load.
* Canvas is the default for everyone else — the original user
* feedback was that the list shape made flows "hard to understand".
*/
import { useState } from "react";
import { LayoutGrid, ListTree } from "lucide-react";
import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view";
interface Props {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
}
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
// Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only
// mounts us AFTER a client-side fetch resolves — there's no SSR
// pass for this subtree, so no hydration mismatch to worry about.
// Default to `canvas` (the new default) when nothing is saved.
const [view, setView] = useState<View>(() => {
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === "canvas" || saved === "list") return saved;
} catch {
// Private browsing / disabled storage — fall through to default.
}
return "canvas";
});
const choose = (next: View) => {
setView(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-end">
<div
role="group"
aria-label="Editor view"
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
>
<ToggleButton
active={view === "canvas"}
onClick={() => choose("canvas")}
icon={<LayoutGrid className="h-3 w-3" />}
label="Canvas"
/>
<ToggleButton
active={view === "list"}
onClick={() => choose("list")}
icon={<ListTree className="h-3 w-3" />}
label="List"
/>
</div>
</div>
{view === "canvas" ? (
<FlowCanvas initialFlow={initialFlow} initialNodes={initialNodes} />
) : (
<FlowBuilder initialFlow={initialFlow} initialNodes={initialNodes} />
)}
</div>
);
}
function ToggleButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
active
? "bg-slate-700 text-slate-100"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200",
)}
>
{icon}
{label}
</button>
);
}

View File

@ -0,0 +1,236 @@
/**
* Shared editor primitives used by both the linear-list and canvas
* views of a flow.
*
* What lives here vs in flow-builder.tsx / flow-canvas.tsx:
* - Types and metadata that BOTH views need to render a node
* consistently (icon, label, color, 1-line summary).
* - Editing-only helpers (defaultConfigFor, slugify, uniqueNodeKey,
* BuilderState) stay in flow-builder.tsx until the canvas grows
* editing affordances — pulled across in the PR that adds them.
*
* Why .tsx and not .ts: NODE_META holds lucide icon components, which
* are typed as React components; importing them from a .ts module
* works at runtime but trips TypeScript's
* `verbatimModuleSyntax`-related linting in some setups. Keeping the
* file .tsx future-proofs it for inline JSX in node-card renderers.
*/
import {
Flag,
GitFork,
Inbox,
ListChecks,
ListPlus,
MessageCircle,
Paperclip,
PlayCircle,
Tag,
UserPlus,
Workflow,
} from "lucide-react";
// ============================================================
// Node-type union — single source of truth for every place the UI
// enumerates types (add menu, type pickers, switch statements). Kept
// in lockstep with `FlowNodeType` in src/lib/flows/types.ts (which
// drives the engine's exhaustiveness check); a divergence between the
// two is always a bug.
// ============================================================
export type NodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "send_media"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
export interface BuilderNode {
node_key: string;
node_type: NodeType;
config: Record<string, unknown>;
/** Optional in v1 — defaults to 0 in the DB. Canvas view reads it
* to position nodes; list view ignores it. */
position_x?: number;
position_y?: number;
}
// ============================================================
// Per-node-type metadata used to render icons + labels everywhere
// the user sees a node summary.
// ============================================================
export const NODE_META: Record<
NodeType,
{ label: string; icon: typeof Workflow; color: string }
> = {
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
send_message: {
label: "Send message",
icon: MessageCircle,
color: "text-sky-400",
},
send_buttons: {
label: "Send buttons",
icon: ListChecks,
color: "text-primary",
},
send_list: {
label: "Send list",
icon: ListPlus,
color: "text-indigo-400",
},
send_media: {
label: "Send media",
icon: Paperclip,
color: "text-cyan-400",
},
collect_input: {
label: "Collect input",
icon: Inbox,
color: "text-teal-400",
},
condition: {
label: "If / else",
icon: GitFork,
color: "text-fuchsia-400",
},
set_tag: {
label: "Tag contact",
icon: Tag,
color: "text-pink-400",
},
handoff: {
label: "Handoff to agent",
icon: UserPlus,
color: "text-amber-400",
},
end: { label: "End", icon: Flag, color: "text-slate-400" },
};
// ============================================================
// Summary helpers — short, single-line content previews used in
// collapsed node cards (list view) and node tiles (canvas view).
// Returns null when there's nothing meaningful to show (start/end,
// or a freshly-added node with no fields filled in).
// ============================================================
export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, " ").trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + "…";
}
export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "end":
return null;
case "send_message": {
const text = typeof cfg.text === "string" ? cfg.text : "";
return text.length > 0 ? truncate(text) : null;
}
case "send_buttons": {
const text = typeof cfg.text === "string" ? cfg.text : "";
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : ""))
.filter(Boolean)
.join(" / ");
if (text.length > 0) {
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text);
}
return titles || null;
}
case "send_list": {
const text = typeof cfg.text === "string" ? cfg.text : "";
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
const rowCount = sections.reduce<number>((sum, s) => {
const rows = Array.isArray(s.rows) ? s.rows : [];
return sum + rows.length;
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
: truncate(text);
}
return rowCount > 0
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}`
: null;
}
case "send_media": {
const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : "";
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media";
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file";
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case "collect_input": {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
if (prompt.length > 0) {
return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case "condition": {
const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
if (!subjectKey) return null;
const subject =
cfg.subject === "tag"
? "tag"
: cfg.subject === "contact_field"
? "field"
: "var";
const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
const op =
cfg.operator === "equals"
? "=="
: cfg.operator === "contains"
? "contains"
: cfg.operator === "present"
? "exists"
: cfg.operator === "absent"
? "missing"
: "";
const value = typeof cfg.value === "string" ? cfg.value : "";
const valStr =
(cfg.operator === "equals" || cfg.operator === "contains") && value
? ` "${truncate(value, 20)}"`
: "";
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case "set_tag": {
const mode = cfg.mode === "remove" ? "Remove" : "Add";
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : "";
// No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance.
return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`;
}
case "handoff": {
const note = typeof cfg.note === "string" ? cfg.note : "";
return note.length > 0 ? truncate(note) : null;
}
}
}

287
src/lib/flows/edges.test.ts Normal file
View File

@ -0,0 +1,287 @@
import { describe, it, expect } from "vitest";
import { deriveCanvasEdges } from "./edges";
import type { BuilderNode } from "@/components/flows/shared";
function nodes(...ns: BuilderNode[]): BuilderNode[] {
return ns;
}
describe("deriveCanvasEdges — single-outgoing node types", () => {
it("derives a `next` edge from send_message", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "b" },
},
{ node_key: "b", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: "a",
target: "b",
sourceHandle: "next",
});
});
it("derives a `next` edge from send_media, set_tag, collect_input, start", () => {
const edges = deriveCanvasEdges(
nodes(
{ node_key: "s", node_type: "start", config: { next_node_key: "m" } },
{
node_key: "m",
node_type: "send_media",
config: {
media_type: "image",
media_url: "https://x/y.png",
next_node_key: "t",
},
},
{
node_key: "t",
node_type: "set_tag",
config: { mode: "add", tag_id: "u", next_node_key: "ci" },
},
{
node_key: "ci",
node_type: "collect_input",
config: {
prompt_text: "p",
var_key: "v",
next_node_key: "e",
},
},
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(4);
expect(edges.map((e) => `${e.source}->${e.target}`)).toEqual([
"s->m",
"m->t",
"t->ci",
"ci->e",
]);
});
it("skips dangling edges (next_node_key pointing nowhere)", () => {
const edges = deriveCanvasEdges(
nodes({
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "ghost" },
}),
);
expect(edges).toEqual([]);
});
it("skips empty next_node_key (fresh node)", () => {
const edges = deriveCanvasEdges(
nodes({
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "" },
}),
);
expect(edges).toEqual([]);
});
});
describe("deriveCanvasEdges — condition (true/false branches)", () => {
it("produces a labeled edge for each branch", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "equals",
value: "y",
true_next: "t",
false_next: "f",
},
},
{ node_key: "t", node_type: "end", config: {} },
{ node_key: "f", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges.find((e) => e.sourceHandle === "true")).toMatchObject({
target: "t",
label: "true",
});
expect(edges.find((e) => e.sourceHandle === "false")).toMatchObject({
target: "f",
label: "false",
});
});
it("emits whichever branches are set when one points nowhere", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "present",
true_next: "t",
false_next: "",
},
},
{ node_key: "t", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0].sourceHandle).toBe("true");
});
});
describe("deriveCanvasEdges — send_buttons (per-button)", () => {
it("emits one edge per button, labeled with the button title", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "menu",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "ok" },
{ reply_id: "no", title: "No", next_node_key: "bye" },
],
},
},
{ node_key: "ok", node_type: "handoff", config: {} },
{ node_key: "bye", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges[0]).toMatchObject({
source: "menu",
target: "ok",
sourceHandle: "button:yes",
label: "Yes",
});
expect(edges[1]).toMatchObject({
source: "menu",
target: "bye",
sourceHandle: "button:no",
label: "No",
});
});
it("falls back to reply_id when title is missing", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "raw", next_node_key: "e" }],
},
},
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges[0].label).toBe("raw");
});
it("skips buttons whose target doesn't exist", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "good", title: "G", next_node_key: "real" },
{ reply_id: "bad", title: "B", next_node_key: "ghost" },
],
},
},
{ node_key: "real", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0].sourceHandle).toBe("button:good");
});
});
describe("deriveCanvasEdges — send_list (per-row across sections)", () => {
it("emits one edge per row, with `row:<reply_id>` handles", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "list",
node_type: "send_list",
config: {
text: "Pick",
button_label: "View",
sections: [
{
title: "Recent",
rows: [
{ reply_id: "o1", title: "Order 1", next_node_key: "a" },
],
},
{
title: "Older",
rows: [
{ reply_id: "o2", title: "Order 2", next_node_key: "b" },
],
},
],
},
},
{ node_key: "a", node_type: "handoff", config: {} },
{ node_key: "b", node_type: "handoff", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges[0].sourceHandle).toBe("row:o1");
expect(edges[0].label).toBe("Order 1");
expect(edges[1].sourceHandle).toBe("row:o2");
});
});
describe("deriveCanvasEdges — terminal nodes", () => {
it("emits no outgoing edges from handoff / end", () => {
const edges = deriveCanvasEdges(
nodes(
{ node_key: "h", node_type: "handoff", config: { note: "x" } },
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges).toEqual([]);
});
});
describe("deriveCanvasEdges — id stability", () => {
it("produces unique, deterministic ids per (source, slot, target)", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "x" },
{ reply_id: "b", title: "B", next_node_key: "x" },
],
},
},
{ node_key: "x", node_type: "end", config: {} },
),
);
const ids = edges.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

149
src/lib/flows/edges.ts Normal file
View File

@ -0,0 +1,149 @@
/**
* 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;
}

View File

@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import { autoLayout, shouldAutoLayout } from "./layout";
describe("shouldAutoLayout", () => {
it("returns false for an empty list", () => {
expect(shouldAutoLayout([])).toBe(false);
});
it("returns true when every node sits at 0,0", () => {
expect(
shouldAutoLayout([
{ position_x: 0, position_y: 0 },
{ position_x: 0, position_y: 0 },
]),
).toBe(true);
});
it("treats null / undefined positions as 0,0", () => {
expect(
shouldAutoLayout([
{ position_x: null, position_y: null },
{},
]),
).toBe(true);
});
it("returns false if any node has a non-zero position (mid-edit guard)", () => {
expect(
shouldAutoLayout([
{ position_x: 0, position_y: 0 },
{ position_x: 200, position_y: 50 },
]),
).toBe(false);
});
});
describe("autoLayout", () => {
it("returns a position for every input node", () => {
const positions = autoLayout(
[
{ id: "a" },
{ id: "b" },
{ id: "c" },
],
[
{ source: "a", target: "b" },
{ source: "b", target: "c" },
],
);
expect(positions.size).toBe(3);
expect(positions.has("a")).toBe(true);
expect(positions.has("b")).toBe(true);
expect(positions.has("c")).toBe(true);
});
it("lays a linear chain top-to-bottom by default", () => {
const positions = autoLayout(
[
{ id: "a" },
{ id: "b" },
{ id: "c" },
],
[
{ source: "a", target: "b" },
{ source: "b", target: "c" },
],
);
const a = positions.get("a")!;
const b = positions.get("b")!;
const c = positions.get("c")!;
// TB direction => y increases down the chain.
expect(a.y).toBeLessThan(b.y);
expect(b.y).toBeLessThan(c.y);
});
it("spreads branch targets horizontally on the same rank", () => {
const positions = autoLayout(
[
{ id: "root" },
{ id: "left" },
{ id: "right" },
],
[
{ source: "root", target: "left" },
{ source: "root", target: "right" },
],
);
const left = positions.get("left")!;
const right = positions.get("right")!;
// Same rank => same y; different positions horizontally.
expect(left.y).toBe(right.y);
expect(left.x).not.toBe(right.x);
});
it("ignores edges whose endpoints aren't in the node list", () => {
// Defensive — the canvas filters dangling edges but the helper
// shouldn't blow up if a stale edge slips through.
const positions = autoLayout(
[{ id: "only" }],
[
{ source: "only", target: "ghost" },
{ source: "phantom", target: "only" },
],
);
expect(positions.size).toBe(1);
expect(positions.get("only")).toBeDefined();
});
it("respects custom node widths when computing positions", () => {
const narrow = autoLayout(
[
{ id: "a", width: 100, height: 50 },
{ id: "b", width: 100, height: 50 },
],
[{ source: "a", target: "b" }],
);
const wide = autoLayout(
[
{ id: "a", width: 400, height: 50 },
{ id: "b", width: 400, height: 50 },
],
[{ source: "a", target: "b" }],
);
// Wider nodes don't shift vertical spacing on a single chain
// (rank gap is fixed) but they DO offset x to keep nodes centered.
expect(narrow.get("a")!.y).toBe(wide.get("a")!.y);
});
});

131
src/lib/flows/layout.ts Normal file
View File

@ -0,0 +1,131 @@
/**
* Dagre-based auto-layout for the flow canvas.
*
* The canvas reads `flow_nodes.position_x` / `position_y` (added in
* migration 010 as `INTEGER NOT NULL DEFAULT 0` — reserved precisely
* for this view). Brand-new flows and every flow authored before the
* canvas shipped have all-zero positions, which would render as a
* single overlapping pile at the origin. This module computes
* reasonable starting positions in those cases.
*
* Why dagre over a hand-rolled BFS layout: branches with multiple
* outgoing edges (send_buttons, condition, send_list) need horizontal
* spread to be readable, and dagre's `rank`+`order` pass handles edge
* crossings far better than anything we'd write by hand. ~30 KB gz
* for the standalone wrapper, but the canvas already pulls in
* @xyflow/react so this is incremental.
*
* What we do NOT do here: re-layout on every edit. The canvas
* persists the user's drag positions, and we only ever auto-layout
* once when `shouldAutoLayout()` returns true. Otherwise a user who
* carefully arranged a flow would have their work overwritten on
* reload.
*/
import Dagre from "@dagrejs/dagre";
export interface LayoutNode {
id: string;
/** Optional measured size — falls back to defaults if not provided. */
width?: number;
height?: number;
}
export interface LayoutEdge {
source: string;
target: string;
}
export interface LayoutPosition {
x: number;
y: number;
}
export interface LayoutOptions {
/** Top-to-bottom is the natural reading order for conversation flows. */
direction?: "TB" | "LR";
/** Gap between rows (TB) / columns (LR). */
rankSep?: number;
/** Gap between sibling nodes within the same rank. */
nodeSep?: number;
/** Default node width when a node's width isn't measured yet. */
defaultWidth?: number;
/** Default node height when a node's height isn't measured yet. */
defaultHeight?: number;
}
const DEFAULTS: Required<LayoutOptions> = {
direction: "TB",
rankSep: 80,
nodeSep: 60,
defaultWidth: 240,
defaultHeight: 90,
};
/**
* True iff every node sits at the origin — the signal that no human
* has positioned this flow yet and auto-layout is safe to run.
*
* Why `every`, not `some`: a partially-laid-out flow (some nodes at
* 0,0, others positioned) is almost certainly mid-edit. Re-running
* dagre would shuffle the positioned ones the user already chose.
* Better to leave the new nodes at 0,0 and let the user drag them.
*/
export function shouldAutoLayout(
nodes: Array<{ position_x?: number | null; position_y?: number | null }>,
): boolean {
if (nodes.length === 0) return false;
return nodes.every(
(n) => (n.position_x ?? 0) === 0 && (n.position_y ?? 0) === 0,
);
}
/**
* Compute positions for every node id. Returns a map keyed by node
* id; consumers merge it into their React-Flow nodes array. The
* returned coordinates are the TOP-LEFT corner (matches React-Flow's
* coordinate space — dagre internally tracks centers, we translate).
*/
export function autoLayout(
nodes: LayoutNode[],
edges: LayoutEdge[],
options: LayoutOptions = {},
): Map<string, LayoutPosition> {
const opts = { ...DEFAULTS, ...options };
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: opts.direction,
ranksep: opts.rankSep,
nodesep: opts.nodeSep,
});
for (const n of nodes) {
g.setNode(n.id, {
width: n.width ?? opts.defaultWidth,
height: n.height ?? opts.defaultHeight,
});
}
for (const e of edges) {
// Dagre tolerates edges to/from non-existent nodes by inserting
// them as zero-size — that would silently warp the layout. Skip
// dangling edges instead; the canvas's edge derivation already
// filters them but defending here keeps this helper standalone.
if (g.node(e.source) && g.node(e.target)) {
g.setEdge(e.source, e.target);
}
}
Dagre.layout(g);
const positions = new Map<string, LayoutPosition>();
for (const n of nodes) {
const laid = g.node(n.id);
if (!laid) continue;
// Dagre returns the center; React-Flow wants the top-left.
positions.set(n.id, {
x: laid.x - (n.width ?? opts.defaultWidth) / 2,
y: laid.y - (n.height ?? opts.defaultHeight) / 2,
});
}
return positions;
}