mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
cd5dea1e4e
commit
bc859bcbcd
237
package-lock.json
generated
237
package-lock.json
generated
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
233
src/components/flows/flow-canvas.tsx
Normal file
233
src/components/flows/flow-canvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/flows/flow-editor-shell.tsx
Normal file
120
src/components/flows/flow-editor-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/components/flows/shared.tsx
Normal file
236
src/components/flows/shared.tsx
Normal 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
287
src/lib/flows/edges.test.ts
Normal 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
149
src/lib/flows/edges.ts
Normal 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;
|
||||||
|
}
|
||||||
128
src/lib/flows/layout.test.ts
Normal file
128
src/lib/flows/layout.test.ts
Normal 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
131
src/lib/flows/layout.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user