feat(flows): group the add-step menu by node category

The canvas and list add-node menus both listed all 10 node types in
one flat dropdown. As the type list grows — especially for forks
adding their own node types (see #312) — a flat list gets hard to
scan. Bucket the types into Messaging / Logic & data / Flow control,
driven by a new `category` field on NODE_META so a new type can't go
uncategorized, and a `groupNodeTypesByCategory` helper shared by both
menus so they stay in lockstep.

No triggers/events category: in wacrm a flow is triggered by
flow-level config (trigger_type), not a canvas node, so `start` lives
under Flow control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-29 15:21:07 +03:00
parent 39aeac8c0c
commit ce0d04ba80
No known key found for this signature in database
4 changed files with 429 additions and 299 deletions

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
/** /**
* Linear-list flow editor. * Linear-list flow editor.
@ -17,7 +17,7 @@
* are list-only and have no canvas analogue. * are list-only and have no canvas analogue.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
CircleAlert, CircleAlert,
Plus, Plus,
@ -25,41 +25,41 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
CornerDownRight, CornerDownRight,
} from "lucide-react"; } from 'lucide-react';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { type ValidationIssue } from "@/lib/flows/validate"; import { type ValidationIssue } from '@/lib/flows/validate';
import { import {
NODE_META, NODE_META,
NodeIconChip, NodeIconChip,
groupNodeTypesByCategory,
nodeColors, nodeColors,
slugify, slugify,
summarizeNode, summarizeNode,
type BuilderNode, type BuilderNode,
type NodeType, type NodeType,
} from "./shared"; } from './shared';
import { NodeConfigForm } from "./forms/node-config-form"; import { NodeConfigForm } from './forms/node-config-form';
import { NodeKeySelect } from "./forms/fields"; import { NodeKeySelect } from './forms/fields';
import { IssueLine } from "./validation-panel"; import { IssueLine } from './validation-panel';
import { import { useFlowEditor, type BuilderState } from './flow-editor-state';
useFlowEditor,
type BuilderState,
} from "./flow-editor-state";
// ============================================================ // ============================================================
// Local state shape — mirrors the DB but the configs are typed // Local state shape — mirrors the DB but the configs are typed
@ -87,7 +87,7 @@ export function FlowBuilder() {
// jump-to-node. The flash itself is read from context (flashKey) // jump-to-node. The flash itself is read from context (flashKey)
// so canvas + list share the same source of truth. // so canvas + list share the same source of truth.
const [expanded, setExpanded] = useState<Set<string>>( const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(state.nodes.map((n) => n.node_key)), () => new Set(state.nodes.map((n) => n.node_key))
); );
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@ -99,7 +99,7 @@ export function FlowBuilder() {
const key = addNodeCtx(type); const key = addNodeCtx(type);
setExpanded((prev) => new Set([...prev, key])); setExpanded((prev) => new Set([...prev, key]));
}, },
[addNodeCtx], [addNodeCtx]
); );
const removeNode = useCallback( const removeNode = useCallback(
@ -111,7 +111,7 @@ export function FlowBuilder() {
return next; return next;
}); });
}, },
[removeNodeCtx], [removeNodeCtx]
); );
const toggleExpanded = useCallback((key: string) => { const toggleExpanded = useCallback((key: string) => {
@ -128,7 +128,7 @@ export function FlowBuilder() {
if (el) nodeRefs.current.set(key, el); if (el) nodeRefs.current.set(key, el);
else nodeRefs.current.delete(key); else nodeRefs.current.delete(key);
}, },
[], []
); );
// React to validator jumps via the shared flashKey. We DERIVE the // React to validator jumps via the shared flashKey. We DERIVE the
@ -147,7 +147,7 @@ export function FlowBuilder() {
// committed any expand-induced layout shift. // committed any expand-induced layout shift.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = nodeRefs.current.get(flashKey); const el = nodeRefs.current.get(flashKey);
el?.scrollIntoView({ behavior: "smooth", block: "center" }); el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}); });
}, [flashKey]); }, [flashKey]);
@ -156,24 +156,24 @@ export function FlowBuilder() {
<TriggerPanel <TriggerPanel
state={state} state={state}
setState={setState} setState={setState}
triggerIssues={issues.filter((i) => i.scope === "trigger")} triggerIssues={issues.filter((i) => i.scope === 'trigger')}
/> />
<EntryPicker state={state} setState={setState} /> <EntryPicker state={state} setState={setState} />
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground"> <h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length}) Nodes ({state.nodes.length})
</h2> </h2>
<AddNodeButton onAdd={addNode} /> <AddNodeButton onAdd={addNode} />
</div> </div>
{state.nodes.length === 0 ? ( {state.nodes.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-card/50 p-8 text-center text-sm text-muted-foreground"> <div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a <strong>Send buttons</strong> Add a <strong>Start</strong> node, then a{' '}
{" "}node, then a <strong>Handoff</strong> — that&apos;s the welcome-menu <strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
shape from the brief. — that&apos;s the welcome-menu shape from the brief.
</div> </div>
) : ( ) : (
state.nodes.map((node) => ( state.nodes.map((node) => (
@ -186,7 +186,7 @@ export function FlowBuilder() {
isFlashed={flashKey === node.node_key} isFlashed={flashKey === node.node_key}
cardRef={setNodeRef(node.node_key)} cardRef={setNodeRef(node.node_key)}
issues={issues.filter( issues={issues.filter(
(i) => i.scope === "node" && i.node_key === node.node_key, (i) => i.scope === 'node' && i.node_key === node.node_key
)} )}
onToggle={() => toggleExpanded(node.node_key)} onToggle={() => toggleExpanded(node.node_key)}
onUpdate={(patch) => updateNode(node.node_key, patch)} onUpdate={(patch) => updateNode(node.node_key, patch)}
@ -203,7 +203,6 @@ export function FlowBuilder() {
); );
} }
// ============================================================ // ============================================================
// Keyword trigger input // Keyword trigger input
// ============================================================ // ============================================================
@ -225,14 +224,14 @@ function KeywordsInput({
keywords: string[]; keywords: string[];
onChange: (keywords: string[]) => void; onChange: (keywords: string[]) => void;
}) { }) {
const [draft, setDraft] = useState(keywords.join(", ")); const [draft, setDraft] = useState(keywords.join(', '));
function commit() { function commit() {
const parsed = draft const parsed = draft
.split(",") .split(',')
.map((k) => k.trim()) .map((k) => k.trim())
.filter(Boolean); .filter(Boolean);
setDraft(parsed.join(", ")); setDraft(parsed.join(', '));
onChange(parsed); onChange(parsed);
} }
@ -242,7 +241,7 @@ function KeywordsInput({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
commit(); commit();
} }
@ -267,19 +266,21 @@ function TriggerPanel({
triggerIssues: ValidationIssue[]; triggerIssues: ValidationIssue[];
}) { }) {
return ( return (
<section className="rounded-lg border border-border bg-card p-4"> <section className="border-border bg-card rounded-lg border p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground">Trigger</h2> <h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">When…</label> <label className="text-muted-foreground mb-1 block text-xs">
When…
</label>
<Select <Select
value={state.trigger_type} value={state.trigger_type}
onValueChange={(v) => onValueChange={(v) =>
setState((s) => ({ setState((s) => ({
...s, ...s,
trigger_type: v as BuilderState["trigger_type"], trigger_type: v as BuilderState['trigger_type'],
trigger_config: trigger_config:
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {}, v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
})) }))
} }
> >
@ -299,9 +300,9 @@ function TriggerPanel({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{state.trigger_type === "keyword" && ( {state.trigger_type === 'keyword' && (
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground"> <label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated) Keywords (comma-separated)
</label> </label>
<KeywordsInput <KeywordsInput
@ -344,17 +345,15 @@ function EntryPicker({
}) { }) {
if (state.nodes.length === 0) return null; if (state.nodes.length === 0) return null;
return ( return (
<section className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"> <section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" /> <CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-xs text-muted-foreground">Entry node:</span> <span className="text-muted-foreground text-xs">Entry node:</span>
<NodeKeySelect <NodeKeySelect
value={state.entry_node_id} value={state.entry_node_id}
nodes={state.nodes} nodes={state.nodes}
onChange={(key) => onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
setState((s) => ({ ...s, entry_node_id: key }))
}
placeholder="Pick the first node…" placeholder="Pick the first node…"
className="flex-1 max-w-xs" className="max-w-xs flex-1"
/> />
</section> </section>
); );
@ -393,20 +392,19 @@ function NodeCard({
}) { }) {
const meta = NODE_META[node.node_type]; const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type); const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === "error"); const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node); const preview = summarizeNode(node);
return ( return (
<div <div
ref={cardRef} ref={cardRef}
className={cn( className={cn(
"relative overflow-hidden rounded-xl border bg-card transition-shadow duration-500", 'bg-card relative overflow-hidden rounded-xl border transition-shadow duration-500',
hasError hasError
? "border-red-500/40" ? 'border-red-500/40'
: isEntry : isEntry
? "border-primary/50" ? 'border-primary/50'
: "border-border", : 'border-border',
isFlashed && isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
"ring-2 ring-primary ring-offset-2 ring-offset-background",
)} )}
> >
{/* type-colored left rail, ties the list row to the canvas hue */} {/* type-colored left rail, ties the list row to the canvas hue */}
@ -423,25 +421,25 @@ function NodeCard({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="truncate text-[11px] font-semibold uppercase tracking-wider" className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }} style={{ color: c.text }}
> >
{meta.label} {meta.label}
</span> </span>
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key} {node.node_key}
</code> </code>
{isEntry && ( {isEntry && (
<Badge <Badge
variant="outline" variant="outline"
className="border-primary/40 bg-primary/10 text-[10px] text-primary" className="border-primary/40 bg-primary/10 text-primary text-[10px]"
> >
Entry Entry
</Badge> </Badge>
)} )}
</div> </div>
{!expanded && preview && ( {!expanded && preview && (
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <p className="text-muted-foreground mt-0.5 truncate text-xs">
{preview} {preview}
</p> </p>
)} )}
@ -450,20 +448,20 @@ function NodeCard({
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" /> <CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
)} )}
{expanded ? ( {expanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" /> <ChevronUp className="text-muted-foreground h-4 w-4" />
) : ( ) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="text-muted-foreground h-4 w-4" />
)} )}
</button> </button>
{expanded && ( {expanded && (
<div className="border-t border-border px-4 py-4"> <div className="border-border border-t px-4 py-4">
<NodeConfigWithAdvanced <NodeConfigWithAdvanced
node={node} node={node}
allNodes={allNodes} allNodes={allNodes}
onUpdate={onUpdate} onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
/> />
<div className="mt-4 flex items-center justify-between border-t border-border pt-3"> <div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isEntry && ( {!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}> <Button variant="ghost" size="sm" onClick={onSetEntry}>
@ -513,7 +511,7 @@ function NodeConfigWithAdvanced({
}) { }) {
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds = const hasReplyIds =
node.node_type === "send_buttons" || node.node_type === "send_list"; node.node_type === 'send_buttons' || node.node_type === 'send_list';
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<NodeConfigForm <NodeConfigForm
@ -522,23 +520,23 @@ function NodeConfigWithAdvanced({
showAdvanced={showAdvanced} showAdvanced={showAdvanced}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
/> />
<div className="border-t border-border pt-3"> <div className="border-border border-t pt-3">
<button <button
type="button" type="button"
onClick={() => setShowAdvanced((v) => !v)} onClick={() => setShowAdvanced((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
> >
{showAdvanced ? ( {showAdvanced ? (
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
)} )}
{showAdvanced ? "Hide" : "Show"} advanced {showAdvanced ? 'Hide' : 'Show'} advanced
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground"> <label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics) Node key (internal identifier — keep stable for analytics)
</label> </label>
<Input <Input
@ -550,10 +548,10 @@ function NodeConfigWithAdvanced({
/> />
</div> </div>
{hasReplyIds && ( {hasReplyIds && (
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re Reply IDs for each option are shown inline above. They&apos;re
returned by WhatsApp when a customer taps; you usually don&apos;t returned by WhatsApp when a customer taps; you usually
need to touch them. don&apos;t need to touch them.
</p> </p>
)} )}
</div> </div>
@ -563,45 +561,51 @@ function NodeConfigWithAdvanced({
); );
} }
// ============================================================ // ============================================================
// Add-node menu // Add-node menu
// ============================================================ // ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
const types: NodeType[] = [ const types: NodeType[] = [
"start", 'start',
"send_buttons", 'send_buttons',
"send_list", 'send_list',
"send_message", 'send_message',
"send_media", 'send_media',
"collect_input", 'collect_input',
"condition", 'condition',
"set_tag", 'set_tag',
"handoff", 'handoff',
"end", 'end',
]; ];
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node" aria-label="Add node"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add node Add node
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover"> <DropdownMenuContent align="end" className="border-border bg-popover">
{types.map((t) => { {groupNodeTypesByCategory(types).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t]; const meta = NODE_META[t];
return ( return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}> <DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} /> <meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label} {meta.label}
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
</div>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
/** /**
* Canvas / mind-map view of a flow. Editable, in parity with the * Canvas / mind-map view of a flow. Editable, in parity with the
@ -36,7 +36,7 @@
* list view reads. * list view reads.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
applyNodeChanges, applyNodeChanges,
Background, Background,
@ -55,12 +55,12 @@ import {
type NodeChange, type NodeChange,
type NodeProps, type NodeProps,
type OnNodeDrag, type OnNodeDrag,
} from "@xyflow/react"; } from '@xyflow/react';
import "@xyflow/react/dist/style.css"; import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -68,29 +68,32 @@ import {
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from '@/components/ui/sheet';
import { import {
applyEdgeConnection, applyEdgeConnection,
deriveCanvasEdges, deriveCanvasEdges,
outgoingSlots, outgoingSlots,
} from "@/lib/flows/edges"; } from '@/lib/flows/edges';
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout"; import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
import { import {
NODE_META, NODE_META,
NodeIconChip, NodeIconChip,
groupNodeTypesByCategory,
nodeColors, nodeColors,
summarizeNode, summarizeNode,
type BuilderNode, type BuilderNode,
type NodeType, type NodeType,
} from "./shared"; } from './shared';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu';
import { useFlowEditor } from "./flow-editor-state"; import { useFlowEditor } from './flow-editor-state';
import { NodeConfigForm } from "./forms/node-config-form"; import { NodeConfigForm } from './forms/node-config-form';
// React-Flow node `data` payload — the bits our custom renderer needs. // React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> { interface NodeData extends Record<string, unknown> {
@ -118,11 +121,11 @@ const NODE_HEIGHT = 90;
// (rose) node colors so all branch/port colors stay single-sourced in // (rose) node colors so all branch/port colors stay single-sourced in
// NODE_HUE — a palette tweak there can't leave these stale. // NODE_HUE — a palette tweak there can't leave these stale.
function slotColor(nodeType: NodeType, slotId: string, fallback: string) { function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
if (nodeType === "condition" && slotId === "true") { if (nodeType === 'condition' && slotId === 'true') {
return nodeColors("start").solid; return nodeColors('start').solid;
} }
if (nodeType === "condition" && slotId === "false") { if (nodeType === 'condition' && slotId === 'false') {
return nodeColors("handoff").solid; return nodeColors('handoff').solid;
} }
return fallback; return fallback;
} }
@ -137,7 +140,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
// don't need an incoming Handle. Every other node type accepts // don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the // incoming edges (including terminal handoff / end — they're the
// common targets). // common targets).
const hasTarget = node.node_type !== "start"; const hasTarget = node.node_type !== 'start';
// Single-slot nodes get a single source handle floated on the right // Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons, // edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits // send_list) render slot rows inline so each handle visually sits
@ -147,10 +150,10 @@ function FlowNodeCard({ data, selected }: NodeProps) {
<div <div
style={ style={
{ {
"--nc": c.solid, '--nc': c.solid,
"--nc-soft": c.soft, '--nc-soft': c.soft,
"--nc-ring": c.ring, '--nc-ring': c.ring,
"--nc-text": c.text, '--nc-text': c.text,
borderColor: selected ? c.solid : undefined, borderColor: selected ? c.solid : undefined,
boxShadow: selected boxShadow: selected
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}` ? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
@ -158,21 +161,21 @@ function FlowNodeCard({ data, selected }: NodeProps) {
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
"relative min-w-[220px] max-w-[260px] rounded-xl border bg-card px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]", 'bg-card relative max-w-[260px] min-w-[220px] rounded-xl border px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]',
selected selected
? "border-[var(--nc)]" ? 'border-[var(--nc)]'
: "border-border hover:border-[var(--nc-ring)]", : 'border-border hover:border-[var(--nc-ring)]',
// Flash overrides hover/selected colors briefly. Tailwind's // Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the // built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics. // amber accent matches the list view's flash semantics.
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60", isFlashed && '!border-amber-400 ring-2 ring-amber-400/60'
)} )}
> >
{hasTarget && ( {hasTarget && (
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)] !bg-card" className="!bg-card !h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)]"
/> />
)} )}
@ -184,32 +187,32 @@ function FlowNodeCard({ data, selected }: NodeProps) {
className="rounded-md" className="rounded-md"
/> />
<span <span
className="truncate text-[10.5px] font-semibold uppercase tracking-wider" className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }} style={{ color: c.text }}
> >
{meta.label} {meta.label}
</span> </span>
{isEntry && ( {isEntry && (
<span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground"> <span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry Entry
</span> </span>
)} )}
</div> </div>
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground"> <div className="text-muted-foreground mt-2 truncate font-mono text-[11px]">
{node.node_key} {node.node_key}
</div> </div>
{summary && ( {summary && (
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground"> <div className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{summary} {summary}
</div> </div>
)} )}
{isMultiSlot && ( {isMultiSlot && (
<div className="mt-2.5 flex flex-col gap-1 border-t border-border pt-2.5"> <div className="border-border mt-2.5 flex flex-col gap-1 border-t pt-2.5">
{slots.map((slot) => ( {slots.map((slot) => (
<div <div
key={slot.id} key={slot.id}
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-muted-foreground" className="text-muted-foreground relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px]"
> >
<span className="truncate" title={slot.label}> <span className="truncate" title={slot.label}>
{slot.label} {slot.label}
@ -225,7 +228,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
// sits flush with the right edge of the card instead // sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset // of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius. // matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2 !bg-card" className="!bg-card !relative !top-auto !right-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2"
/> />
</div> </div>
))} ))}
@ -238,7 +241,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
id={slots[0].id} id={slots[0].id}
position={Position.Right} position={Position.Right}
style={{ borderColor: c.solid }} style={{ borderColor: c.solid }}
className="!h-2.5 !w-2.5 !border-2 !bg-card" className="!bg-card !h-2.5 !w-2.5 !border-2"
/> />
)} )}
</div> </div>
@ -286,9 +289,9 @@ function FlowCanvasInner() {
const selectedNode = useMemo( const selectedNode = useMemo(
() => () =>
selectedNodeKey selectedNodeKey
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null ? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
: null, : null,
[selectedNodeKey, builderNodes], [selectedNodeKey, builderNodes]
); );
const autoLayoutPositions = useMemo(() => { const autoLayoutPositions = useMemo(() => {
@ -302,7 +305,7 @@ function FlowCanvasInner() {
height: NODE_HEIGHT, height: NODE_HEIGHT,
})), })),
canvasEdges.map((e) => ({ source: e.source, target: e.target })), canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "TB" }, { direction: 'TB' }
) )
: null; : null;
}, [builderNodes]); }, [builderNodes]);
@ -317,8 +320,8 @@ function FlowCanvasInner() {
persistedAutoLayoutRef.current = true; persistedAutoLayoutRef.current = true;
updateNodePositions( updateNodePositions(
Object.fromEntries( Object.fromEntries(
[...autoLayoutPositions].map(([key, pos]) => [key, pos]), [...autoLayoutPositions].map(([key, pos]) => [key, pos])
), )
); );
}, [autoLayoutPositions, updateNodePositions]); }, [autoLayoutPositions, updateNodePositions]);
@ -327,7 +330,7 @@ function FlowCanvasInner() {
const fallback = autoLayoutPositions?.get(n.node_key); const fallback = autoLayoutPositions?.get(n.node_key);
return { return {
id: n.node_key, id: n.node_key,
type: "flow", type: 'flow',
position: { position: {
x: fallback?.x ?? n.position_x ?? 0, x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0, y: fallback?.y ?? n.position_y ?? 0,
@ -362,11 +365,11 @@ function FlowCanvasInner() {
sourceHandle: e.sourceHandle, sourceHandle: e.sourceHandle,
label: e.label, label: e.label,
// Mode-aware via CSS tokens so edge chrome flips with light/dark. // Mode-aware via CSS tokens so edge chrome flips with light/dark.
labelStyle: { fill: "var(--muted-foreground)", fontSize: 11 }, labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
labelBgStyle: { fill: "var(--card)" }, labelBgStyle: { fill: 'var(--card)' },
labelBgPadding: [4, 2] as [number, number], labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4, labelBgBorderRadius: 4,
style: { stroke: "var(--border)", strokeWidth: 1.5 }, style: { stroke: 'var(--border)', strokeWidth: 1.5 },
})); }));
return rfEdges; return rfEdges;
@ -376,7 +379,7 @@ function FlowCanvasInner() {
(changes: NodeChange<RfNode<NodeData>>[]) => { (changes: NodeChange<RfNode<NodeData>>[]) => {
setRfNodes((nodes) => applyNodeChanges(changes, nodes)); setRfNodes((nodes) => applyNodeChanges(changes, nodes));
}, },
[], []
); );
// Drag-to-position: React-Flow tracks the visual drag internally and // Drag-to-position: React-Flow tracks the visual drag internally and
@ -390,7 +393,7 @@ function FlowCanvasInner() {
(_event, node) => { (_event, node) => {
updateNodePosition(node.id, node.position.x, node.position.y); updateNodePosition(node.id, node.position.x, node.position.y);
}, },
[updateNodePosition], [updateNodePosition]
); );
// Pan to the flashed node when the validator panel requests one. // Pan to the flashed node when the validator panel requests one.
@ -412,7 +415,7 @@ function FlowCanvasInner() {
(_event: React.MouseEvent, node: RfNode<NodeData>) => { (_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id); setSelectedNodeKey(node.id);
}, },
[], []
); );
// Drag-to-connect: React-Flow fires onConnect when the user drops a // Drag-to-connect: React-Flow fires onConnect when the user drops a
@ -423,11 +426,15 @@ function FlowCanvasInner() {
// the next render — no need to maintain a separate edge list. // the next render — no need to maintain a separate edge list.
const handleConnect = useCallback( const handleConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle) { if (
!connection.source ||
!connection.target ||
!connection.sourceHandle
) {
return; return;
} }
const sourceNode = builderNodes.find( const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source, (n) => n.node_key === connection.source
); );
if (!sourceNode) return; if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own // Self-loops are a footgun (a button whose target is its own
@ -437,11 +444,11 @@ function FlowCanvasInner() {
const patch = applyEdgeConnection( const patch = applyEdgeConnection(
sourceNode, sourceNode,
connection.sourceHandle, connection.sourceHandle,
connection.target, connection.target
); );
if (patch) updateNodeConfig(connection.source, patch); if (patch) updateNodeConfig(connection.source, patch);
}, },
[builderNodes, updateNodeConfig], [builderNodes, updateNodeConfig]
); );
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow // Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
@ -457,7 +464,7 @@ function FlowCanvasInner() {
if (selectedNodeKey === n.id) setSelectedNodeKey(null); if (selectedNodeKey === n.id) setSelectedNodeKey(null);
} }
}, },
[removeNode, selectedNodeKey], [removeNode, selectedNodeKey]
); );
// Edge delete: clear the source node's slot rather than removing // Edge delete: clear the source node's slot rather than removing
@ -469,11 +476,11 @@ function FlowCanvasInner() {
if (!e.sourceHandle) continue; if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source); const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue; if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, ""); const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
if (patch) updateNodeConfig(e.source, patch); if (patch) updateNodeConfig(e.source, patch);
} }
}, },
[builderNodes, updateNodeConfig], [builderNodes, updateNodeConfig]
); );
// Wrapped mutators that target the currently-selected node — pass to // Wrapped mutators that target the currently-selected node — pass to
@ -483,7 +490,7 @@ function FlowCanvasInner() {
(patch: Record<string, unknown>) => { (patch: Record<string, unknown>) => {
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch); if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
}, },
[selectedNodeKey, updateNodeConfig], [selectedNodeKey, updateNodeConfig]
); );
const handleDeleteSelected = useCallback(() => { const handleDeleteSelected = useCallback(() => {
@ -499,7 +506,7 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) { if (rfNodes.length === 0) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground"> <div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p> <p>No nodes yet.</p>
<CanvasAddNodeButton /> <CanvasAddNodeButton />
</div> </div>
@ -524,7 +531,7 @@ function FlowCanvasInner() {
onEdgesDelete={handleEdgesDelete} onEdgesDelete={handleEdgesDelete}
// Default is "Backspace" only — accept both so Mac users // Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior. // hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={['Backspace', 'Delete']}
nodesConnectable={true} nodesConnectable={true}
edgesFocusable={true} edgesFocusable={true}
elementsSelectable={true} elementsSelectable={true}
@ -542,7 +549,7 @@ function FlowCanvasInner() {
color="var(--border)" color="var(--border)"
/> />
<Controls <Controls
className="!overflow-hidden !rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground" className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground !overflow-hidden !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
showInteractive={false} showInteractive={false}
/> />
<MiniMap <MiniMap
@ -554,9 +561,9 @@ function FlowCanvasInner() {
nodeStrokeWidth={0} nodeStrokeWidth={0}
nodeBorderRadius={3} nodeBorderRadius={3}
maskColor="color-mix(in oklch, var(--background) 70%, transparent)" maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
className="!rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]" className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/> />
<Panel position="top-left" className="!left-4 !top-4"> <Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton /> <CanvasAddNodeButton />
</Panel> </Panel>
</ReactFlow> </ReactFlow>
@ -614,24 +621,24 @@ function NodeEditSheet({
<Sheet open={open} onOpenChange={(v) => !v && onClose()}> <Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent <SheetContent
side="right" side="right"
className="flex w-full flex-col gap-0 border-l border-border bg-popover p-0 sm:max-w-md" className="border-border bg-popover flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
> >
<SheetHeader className="flex-row items-center gap-3 space-y-0 border-b border-border px-5 py-4"> <SheetHeader className="border-border flex-row items-center gap-3 space-y-0 border-b px-5 py-4">
<NodeIconChip type={node.node_type} size={36} iconSize={18} /> <NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider"> <SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span> <span style={{ color: c.text }}>{meta.label}</span>
{isEntry && ( {isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300"> <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry Entry
</span> </span>
)} )}
</SheetTitle> </SheetTitle>
<SheetDescription className="mt-0.5 text-xs text-muted-foreground"> <SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb} {meta.blurb}
</SheetDescription> </SheetDescription>
</div> </div>
<code className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> <code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
{node.node_key} {node.node_key}
</code> </code>
</SheetHeader> </SheetHeader>
@ -645,7 +652,7 @@ function NodeEditSheet({
/> />
</div> </div>
<SheetFooter className="border-t border-border px-5 py-3 sm:flex-row sm:justify-between"> <SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? ( {!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}> <Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry Set as entry
@ -676,16 +683,16 @@ function NodeEditSheet({
// ============================================================ // ============================================================
const ADD_NODE_TYPES: NodeType[] = [ const ADD_NODE_TYPES: NodeType[] = [
"start", 'start',
"send_buttons", 'send_buttons',
"send_list", 'send_list',
"send_message", 'send_message',
"send_media", 'send_media',
"collect_input", 'collect_input',
"condition", 'condition',
"set_tag", 'set_tag',
"handoff", 'handoff',
"end", 'end',
]; ];
function CanvasAddNodeButton() { function CanvasAddNodeButton() {
@ -699,7 +706,7 @@ function CanvasAddNodeButton() {
// .react-flow root and read its bounding rect. If we can't find // .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback // it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view. // and the user can drag the node into view.
const root = document.querySelector(".react-flow") as HTMLElement | null; const root = document.querySelector('.react-flow') as HTMLElement | null;
if (!root) return; if (!root) return;
const rect = root.getBoundingClientRect(); const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({ const center = reactFlow.screenToFlowPosition({
@ -709,13 +716,17 @@ function CanvasAddNodeButton() {
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset // NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the // so the card sits visually centered rather than top-left at the
// viewport center. // viewport center.
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2); updateNodePosition(
key,
center.x - NODE_WIDTH / 2,
center.y - NODE_HEIGHT / 2
);
}; };
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-[13px] font-medium text-primary-foreground shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors hover:bg-primary-hover" className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node" aria-label="Add node"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -723,12 +734,15 @@ function CanvasAddNodeButton() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="w-[268px] border-border bg-popover p-1.5" className="border-border bg-popover w-[268px] p-1.5"
> >
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> {groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
Add a step <div key={group.id}>
</div> {i > 0 && <DropdownMenuSeparator />}
{ADD_NODE_TYPES.map((t) => { <DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t]; const meta = NODE_META[t];
return ( return (
<DropdownMenuItem <DropdownMenuItem
@ -736,18 +750,25 @@ function CanvasAddNodeButton() {
onClick={() => handleAdd(t)} onClick={() => handleAdd(t)}
className="gap-3 py-2" className="gap-3 py-2"
> >
<NodeIconChip type={t} size={28} iconSize={16} className="rounded-md" /> <NodeIconChip
type={t}
size={28}
iconSize={16}
className="rounded-md"
/>
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-[13px] font-semibold text-popover-foreground"> <span className="text-popover-foreground text-[13px] font-semibold">
{meta.label} {meta.label}
</span> </span>
<span className="text-[11.5px] text-muted-foreground"> <span className="text-muted-foreground text-[11.5px]">
{meta.blurb} {meta.blurb}
</span> </span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
</div>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
NODE_CATEGORIES,
NODE_META,
groupNodeTypesByCategory,
type NodeType,
} from './shared';
const ALL_TYPES = Object.keys(NODE_META) as NodeType[];
describe('node categories', () => {
it('assigns every node type to a known category', () => {
const known = new Set(NODE_CATEGORIES.map((c) => c.id));
for (const type of ALL_TYPES) {
expect(known.has(NODE_META[type].category)).toBe(true);
}
});
});
describe('groupNodeTypesByCategory', () => {
it('keeps the categories in NODE_CATEGORIES order and drops empty ones', () => {
// Only messaging + flow types — the logic group must not appear.
const groups = groupNodeTypesByCategory(['send_message', 'start', 'end']);
expect(groups.map((g) => g.id)).toEqual(['messaging', 'flow']);
});
it('preserves the input order within a category', () => {
const groups = groupNodeTypesByCategory([
'send_media',
'send_message',
'send_buttons',
]);
expect(groups).toHaveLength(1);
expect(groups[0].types).toEqual([
'send_media',
'send_message',
'send_buttons',
]);
});
it('partitions the full type list without losing or duplicating a type', () => {
const grouped = groupNodeTypesByCategory(ALL_TYPES).flatMap((g) => g.types);
expect([...grouped].sort()).toEqual([...ALL_TYPES].sort());
});
});

View File

@ -28,9 +28,9 @@ import {
Tag, Tag,
UserPlus, UserPlus,
Workflow, Workflow,
} from "lucide-react"; } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
// ============================================================ // ============================================================
// Node-type union — single source of truth for every place the UI // Node-type union — single source of truth for every place the UI
@ -41,16 +41,16 @@ import { cn } from "@/lib/utils";
// ============================================================ // ============================================================
export type NodeType = export type NodeType =
| "start" | 'start'
| "send_message" | 'send_message'
| "send_buttons" | 'send_buttons'
| "send_list" | 'send_list'
| "send_media" | 'send_media'
| "collect_input" | 'collect_input'
| "condition" | 'condition'
| "set_tag" | 'set_tag'
| "handoff" | 'handoff'
| "end"; | 'end';
export interface BuilderNode { export interface BuilderNode {
node_key: string; node_key: string;
@ -67,72 +67,123 @@ export interface BuilderNode {
// the user sees a node summary. // the user sees a node summary.
// ============================================================ // ============================================================
// ------------------------------------------------------------
// Node categories — buckets the add-step menu groups types under so
// the picker stays scannable as the type list grows, and so a fork
// adding its own node types has an obvious place to slot them.
//
// Note there's no "Events / Triggers" category: in wacrm a flow is
// triggered by flow-level config (`trigger_type`), not by a node on
// the canvas, so `start` is just the entry point under Flow control.
// ------------------------------------------------------------
export type NodeCategory = 'messaging' | 'logic' | 'flow';
/** Category labels + the order they render in the add-step menu. */
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
{ id: 'messaging', label: 'Messaging' },
{ id: 'logic', label: 'Logic & data' },
{ id: 'flow', label: 'Flow control' },
];
export const NODE_META: Record< export const NODE_META: Record<
NodeType, NodeType,
{ label: string; icon: typeof Workflow; color: string; blurb: string } {
label: string;
icon: typeof Workflow;
color: string;
blurb: string;
category: NodeCategory;
}
> = { > = {
start: { start: {
label: "Start", label: 'Start',
icon: PlayCircle, icon: PlayCircle,
color: "text-emerald-400", color: 'text-emerald-400',
blurb: "Entry point of the flow", blurb: 'Entry point of the flow',
category: 'flow',
}, },
send_message: { send_message: {
label: "Send message", label: 'Send message',
icon: MessageCircle, icon: MessageCircle,
color: "text-sky-400", color: 'text-sky-400',
blurb: "Sends a WhatsApp text message", blurb: 'Sends a WhatsApp text message',
category: 'messaging',
}, },
send_buttons: { send_buttons: {
label: "Send buttons", label: 'Send buttons',
icon: ListChecks, icon: ListChecks,
color: "text-primary", color: 'text-primary',
blurb: "Sends quick-reply buttons", blurb: 'Sends quick-reply buttons',
category: 'messaging',
}, },
send_list: { send_list: {
label: "Send list", label: 'Send list',
icon: ListPlus, icon: ListPlus,
color: "text-indigo-400", color: 'text-indigo-400',
blurb: "Sends a tappable list of options", blurb: 'Sends a tappable list of options',
category: 'messaging',
}, },
send_media: { send_media: {
label: "Send media", label: 'Send media',
icon: Paperclip, icon: Paperclip,
color: "text-cyan-400", color: 'text-cyan-400',
blurb: "Sends an image, video, or document", blurb: 'Sends an image, video, or document',
category: 'messaging',
}, },
collect_input: { collect_input: {
label: "Collect input", label: 'Collect input',
icon: Inbox, icon: Inbox,
color: "text-teal-400", color: 'text-teal-400',
blurb: "Asks a question, saves the reply", blurb: 'Asks a question, saves the reply',
category: 'logic',
}, },
condition: { condition: {
label: "If / else", label: 'If / else',
icon: GitFork, icon: GitFork,
color: "text-fuchsia-400", color: 'text-fuchsia-400',
blurb: "Branches on a rule", blurb: 'Branches on a rule',
category: 'logic',
}, },
set_tag: { set_tag: {
label: "Tag contact", label: 'Tag contact',
icon: Tag, icon: Tag,
color: "text-pink-400", color: 'text-pink-400',
blurb: "Adds or removes a contact tag", blurb: 'Adds or removes a contact tag',
category: 'logic',
}, },
handoff: { handoff: {
label: "Handoff to agent", label: 'Handoff to agent',
icon: UserPlus, icon: UserPlus,
color: "text-amber-400", color: 'text-amber-400',
blurb: "Hands the conversation to a human", blurb: 'Hands the conversation to a human',
category: 'flow',
}, },
end: { end: {
label: "End", label: 'End',
icon: Flag, icon: Flag,
color: "text-muted-foreground", color: 'text-muted-foreground',
blurb: "Ends the flow", blurb: 'Ends the flow',
category: 'flow',
}, },
}; };
/**
* Bucket an ordered list of node types by category, preserving both
* the category order (NODE_CATEGORIES) and the within-category order
* of the input list. Empty categories are dropped. Used by both the
* canvas and list add-step menus so they stay in lockstep.
*/
export function groupNodeTypesByCategory(
types: NodeType[]
): { id: NodeCategory; label: string; types: NodeType[] }[] {
return NODE_CATEGORIES.map(({ id, label }) => ({
id,
label,
types: types.filter((t) => NODE_META[t].category === id),
})).filter((group) => group.types.length > 0);
}
// ============================================================ // ============================================================
// Per-node-type color system. // Per-node-type color system.
// //
@ -212,8 +263,8 @@ export function NodeIconChip({
return ( return (
<span <span
className={cn( className={cn(
"flex shrink-0 items-center justify-center rounded-lg", 'flex shrink-0 items-center justify-center rounded-lg',
className, className
)} )}
style={{ width: size, height: size, background: c.soft, color: c.solid }} style={{ width: size, height: size, background: c.soft, color: c.solid }}
> >
@ -236,8 +287,8 @@ export function slugify(s: string, fallback: string): string {
const cleaned = s const cleaned = s
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/[^a-z0-9]+/g, "_") .replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ""); .replace(/^_+|_+$/g, '');
return cleaned || fallback; return cleaned || fallback;
} }
@ -249,37 +300,39 @@ export function slugify(s: string, fallback: string): string {
// ============================================================ // ============================================================
export function truncate(s: string, max = 80): string { export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, " ").trim(); const clean = s.replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean; if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + "…"; return clean.slice(0, max - 1) + '…';
} }
export function summarizeNode(node: BuilderNode): string | null { export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config; const cfg = node.config;
switch (node.node_type) { switch (node.node_type) {
case "start": case 'start':
case "end": case 'end':
return null; return null;
case "send_message": { case 'send_message': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
return text.length > 0 ? truncate(text) : null; return text.length > 0 ? truncate(text) : null;
} }
case "send_buttons": { case 'send_buttons': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
const buttons = Array.isArray(cfg.buttons) const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>) ? (cfg.buttons as Array<Record<string, unknown>>)
: []; : [];
const titles = buttons const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : "")) .map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean) .filter(Boolean)
.join(" / "); .join(' / ');
if (text.length > 0) { if (text.length > 0) {
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text); return titles
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
: truncate(text);
} }
return titles || null; return titles || null;
} }
case "send_list": { case 'send_list': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections) const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>) ? (cfg.sections as Array<Record<string, unknown>>)
: []; : [];
@ -289,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0); }, 0);
if (text.length > 0) { if (text.length > 0) {
return rowCount > 0 return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}` ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
: truncate(text); : truncate(text);
} }
return rowCount > 0 return rowCount > 0
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}` ? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null; : null;
} }
case "send_media": { case 'send_media': {
const mediaType = const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : ""; typeof cfg.media_type === 'string' ? cfg.media_type : '';
const filename = typeof cfg.filename === "string" ? cfg.filename : ""; const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
const url = typeof cfg.media_url === "string" ? cfg.media_url : ""; const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === "string" ? cfg.caption : ""; const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1) ? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media"; : 'Media';
if (!url) return `${label} (no file uploaded)`; if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file"; const name = filename || url.split('/').pop() || 'file';
return caption return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}` ? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`; : `${label}: ${truncate(name, 60)}`;
} }
case "collect_input": { case 'collect_input': {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; const prompt = typeof cfg.prompt_text === 'string' ? cfg.prompt_text : '';
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; const varKey = typeof cfg.var_key === 'string' ? cfg.var_key : '';
if (prompt.length > 0) { if (prompt.length > 0) {
return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt); return varKey
? `${truncate(prompt, 50)} → vars.${varKey}`
: truncate(prompt);
} }
return varKey ? `→ vars.${varKey}` : null; return varKey ? `→ vars.${varKey}` : null;
} }
case "condition": { case 'condition': {
const subjectKey = const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : ""; typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null; if (!subjectKey) return null;
const subject = const subject =
cfg.subject === "tag" cfg.subject === 'tag'
? "tag" ? 'tag'
: cfg.subject === "contact_field" : cfg.subject === 'contact_field'
? "field" ? 'field'
: "var"; : 'var';
const subjectStr = const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op = const op =
cfg.operator === "equals" cfg.operator === 'equals'
? "==" ? '=='
: cfg.operator === "contains" : cfg.operator === 'contains'
? "contains" ? 'contains'
: cfg.operator === "present" : cfg.operator === 'present'
? "exists" ? 'exists'
: cfg.operator === "absent" : cfg.operator === 'absent'
? "missing" ? 'missing'
: ""; : '';
const value = typeof cfg.value === "string" ? cfg.value : ""; const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr = const valStr =
(cfg.operator === "equals" || cfg.operator === "contains") && value (cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"` ? ` "${truncate(value, 20)}"`
: ""; : '';
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`; return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
} }
case "set_tag": { case 'set_tag': {
const mode = cfg.mode === "remove" ? "Remove" : "Add"; const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : ""; const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a // No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between // short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance. // multiple set_tag nodes at a glance.
return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`; return tagId
? `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`;
} }
case "handoff": { case 'handoff': {
const note = typeof cfg.note === "string" ? cfg.note : ""; const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null; return note.length > 0 ? truncate(note) : null;
} }
} }