diff --git a/package-lock.json b/package-lock.json index e51d790..b301e0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "license": "MIT", "dependencies": { "@base-ui/react": "^1.5.0", + "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.106.2", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -2874,6 +2891,55 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3621,6 +3687,38 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4239,6 +4337,12 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4524,6 +4628,111 @@ "devOptional": true, "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -10910,6 +11119,34 @@ "peerDependencies": { "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 + } + } } } } diff --git a/package.json b/package.json index 221a279..f4f5e35 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,13 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.106.2", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.3.0", diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index fd57c2a..1bcd408 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; 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"; /** @@ -84,5 +84,5 @@ export default function FlowEditorPage() { ); } - return ; + return ; } diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 27bad69..9b3eb70 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -26,21 +26,13 @@ import { Plus, Save, Trash2, - Workflow, ChevronDown, ChevronUp, - MessageCircle, - ListChecks, - ListPlus, CornerDownRight, - UserPlus, - Flag, - PlayCircle, PauseCircle, - Inbox, - GitFork, - Tag, + PlayCircle, Paperclip, + Workflow, Upload, X, } from "lucide-react"; @@ -70,6 +62,12 @@ import { } from "@/lib/flows/validate"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import { createClient } from "@/lib/supabase/client"; +import { + NODE_META, + summarizeNode, + type BuilderNode, + type NodeType, +} from "./shared"; interface FlowBuilderProps { initialFlow: FlowRow; @@ -82,24 +80,6 @@ interface FlowBuilderProps { // 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; -} - interface BuilderState { name: string; description: string; @@ -110,59 +90,6 @@ interface BuilderState { 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 // ============================================================ @@ -183,125 +110,6 @@ function uniqueNodeKey(base: string, existing: BuilderNode[]): string { 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>) - : []; - 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>) - : []; - const rowCount = sections.reduce((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 { switch (type) { case "start": diff --git a/src/components/flows/flow-canvas.tsx b/src/components/flows/flow-canvas.tsx new file mode 100644 index 0000000..107a7b8 --- /dev/null +++ b/src/components/flows/flow-canvas.tsx @@ -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 `` + * 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 { + 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 ( +
+
+ + + {meta.label} + + {isEntry && ( + + Entry + + )} +
+
+ {node.node_key} +
+ {summary && ( +
+ {summary} +
+ )} +
+ ); +} + +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, + 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[] = 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 ( +
+ No nodes yet. Switch to List view to add your first node. +
+ ); + } + + return ( + +
+ + + + + +
+
+ ); +} diff --git a/src/components/flows/flow-editor-shell.tsx b/src/components/flows/flow-editor-shell.tsx new file mode 100644 index 0000000..e6d89f6 --- /dev/null +++ b/src/components/flows/flow-editor-shell.tsx @@ -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 `` or ``. 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(() => { + 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 ( +
+
+
+ choose("canvas")} + icon={} + label="Canvas" + /> + choose("list")} + icon={} + label="List" + /> +
+
+ + {view === "canvas" ? ( + + ) : ( + + )} +
+ ); +} + +function ToggleButton({ + active, + onClick, + icon, + label, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +}) { + return ( + + ); +} diff --git a/src/components/flows/shared.tsx b/src/components/flows/shared.tsx new file mode 100644 index 0000000..baa55ea --- /dev/null +++ b/src/components/flows/shared.tsx @@ -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; + /** 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>) + : []; + 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>) + : []; + const rowCount = sections.reduce((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; + } + } +} diff --git a/src/lib/flows/edges.test.ts b/src/lib/flows/edges.test.ts new file mode 100644 index 0000000..640e7d0 --- /dev/null +++ b/src/lib/flows/edges.test.ts @@ -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:` 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); + }); +}); diff --git a/src/lib/flows/edges.ts b/src/lib/flows/edges.ts new file mode 100644 index 0000000..b13ec50 --- /dev/null +++ b/src/lib/flows/edges.ts @@ -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:` for send_buttons rows + * - `row:` 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> }).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> }).sections) + : []; + for (const section of sections) { + const rows = Array.isArray(section.rows) + ? (section.rows as Array>) + : []; + for (const row of rows) { + const replyId = + typeof row.reply_id === "string" ? row.reply_id : null; + 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; +} diff --git a/src/lib/flows/layout.test.ts b/src/lib/flows/layout.test.ts new file mode 100644 index 0000000..dce1eaf --- /dev/null +++ b/src/lib/flows/layout.test.ts @@ -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); + }); +}); diff --git a/src/lib/flows/layout.ts b/src/lib/flows/layout.ts new file mode 100644 index 0000000..71c02f3 --- /dev/null +++ b/src/lib/flows/layout.ts @@ -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 = { + 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 { + 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(); + 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; +}