'use client'; /** * Linear-list flow editor. * * Renders the trigger panel, entry-node picker, and the per-node card * list. Header and validation panel are NOT owned here — they live * once in FlowEditorShell so they show in both views (lifted in PR 3 * so canvas users can also save + see validator issues). * * State lives in the shared `useFlowEditor()` context — toggling * Canvas ⇄ List never loses edits, and a drag on the canvas updates * the same nodes the list view reads. * * What's still local: the `expanded` set (which cards are open) and * the scroll refs used when the validator's flashKey changes — those * are list-only and have no canvas analogue. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CircleAlert, Plus, Trash2, ChevronDown, ChevronUp, CornerDownRight, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { type ValidationIssue } from '@/lib/flows/validate'; import { NODE_META, NodeIconChip, groupNodeTypesByCategory, nodeColors, slugify, summarizeNode, type BuilderNode, type NodeType, } from './shared'; import { NodeConfigForm } from './forms/node-config-form'; import { NodeKeySelect } from './forms/fields'; import { IssueLine } from './validation-panel'; import { useFlowEditor, type BuilderState } from './flow-editor-state'; // ============================================================ // Local state shape — mirrors the DB but the configs are typed // loosely (Record) since each node_type carries a // different shape. The sub-form components narrow as needed. // ============================================================ // ============================================================ // Root component // ============================================================ export function FlowBuilder() { const { state, setState, issues, flashKey, addNode: addNodeCtx, updateNode, updateNodeConfig, removeNode: removeNodeCtx, } = useFlowEditor(); // List-only UI state: which cards are expanded + scroll refs for // jump-to-node. The flash itself is read from context (flashKey) // so canvas + list share the same source of truth. const [expanded, setExpanded] = useState>( () => new Set(state.nodes.map((n) => n.node_key)) ); const nodeRefs = useRef>(new Map()); // Wrap addNode so the new node opens expanded in the list view // (matches the previous behaviour where adding always revealed the // new card so the user could start editing immediately). const addNode = useCallback( (type: NodeType) => { const key = addNodeCtx(type); setExpanded((prev) => new Set([...prev, key])); }, [addNodeCtx] ); const removeNode = useCallback( (key: string) => { removeNodeCtx(key); setExpanded((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, [removeNodeCtx] ); const toggleExpanded = useCallback((key: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const setNodeRef = useCallback( (key: string) => (el: HTMLDivElement | null) => { if (el) nodeRefs.current.set(key, el); else nodeRefs.current.delete(key); }, [] ); // React to validator jumps via the shared flashKey. We DERIVE the // expanded-with-flash set (avoids the "setState inside effect" // smell of mutating `expanded` from a useEffect on flashKey), then // run a side-effect-only effect to scroll the row into view. The // flash class is rendered by NodeCard when its key matches // flashKey; the flash auto-clears in the context so no timer here. const expandedWithFlash = useMemo(() => { if (!flashKey || expanded.has(flashKey)) return expanded; return new Set([...expanded, flashKey]); }, [expanded, flashKey]); useEffect(() => { if (!flashKey) return; // requestAnimationFrame defers the scroll until after React has // committed any expand-induced layout shift. requestAnimationFrame(() => { const el = nodeRefs.current.get(flashKey); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }, [flashKey]); return (
i.scope === 'trigger')} />

Nodes ({state.nodes.length})

{state.nodes.length === 0 ? (
Add a Start node, then a{' '} Send buttons node, then a Handoff{' '} — that's the welcome-menu shape from the brief.
) : ( state.nodes.map((node) => ( i.scope === 'node' && i.node_key === node.node_key )} onToggle={() => toggleExpanded(node.node_key)} onUpdate={(patch) => updateNode(node.node_key, patch)} onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} onRemove={() => removeNode(node.node_key)} onSetEntry={() => setState((s) => ({ ...s, entry_node_id: node.node_key })) } /> )) )}
); } // ============================================================ // Keyword trigger input // ============================================================ /** * Comma-separated keyword entry. Keeps a local draft string so the * comma (and trailing space) the user types survive until they're done * — parsing into the keywords array on every keystroke stripped the * trailing comma the instant it was typed, making it impossible to * start a second keyword (issue #234). We commit on blur / Enter, then * re-display the cleaned, rejoined form. Seeded once on mount; the * component unmounts/remounts when the trigger type changes, so the * seed stays in sync. Mirrors the automations builder's KeywordMatchConfig. */ function KeywordsInput({ keywords, onChange, }: { keywords: string[]; onChange: (keywords: string[]) => void; }) { const [draft, setDraft] = useState(keywords.join(', ')); function commit() { const parsed = draft .split(',') .map((k) => k.trim()) .filter(Boolean); setDraft(parsed.join(', ')); onChange(parsed); } return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }} placeholder="support, help, hi" className="bg-muted" /> ); } // ============================================================ // Trigger panel // ============================================================ function TriggerPanel({ state, setState, triggerIssues, }: { state: BuilderState; setState: React.Dispatch>; triggerIssues: ValidationIssue[]; }) { return (

Trigger

{state.trigger_type === 'keyword' && (
setState((s) => ({ ...s, trigger_config: { ...s.trigger_config, keywords }, })) } />
)}
{triggerIssues.length > 0 && (
{triggerIssues.map((i, ix) => ( ))}
)}
); } // ============================================================ // Entry-node picker // ============================================================ function EntryPicker({ state, setState, }: { state: BuilderState; setState: React.Dispatch>; }) { if (state.nodes.length === 0) return null; return (
Entry node: setState((s) => ({ ...s, entry_node_id: key }))} placeholder="Pick the first node…" className="max-w-xs flex-1" />
); } // ============================================================ // Node card — collapsed summary + expanded config form // ============================================================ function NodeCard({ node, allNodes, expanded, isEntry, isFlashed, cardRef, issues, onToggle, onUpdate, onUpdateConfig, onRemove, onSetEntry, }: { node: BuilderNode; allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; isFlashed: boolean; cardRef: (el: HTMLDivElement | null) => void; issues: ValidationIssue[]; onToggle: () => void; onUpdate: (patch: Partial) => void; onUpdateConfig: (patch: Record) => void; onRemove: () => void; onSetEntry: () => void; }) { const meta = NODE_META[node.node_type]; const c = nodeColors(node.node_type); const hasError = issues.some((i) => i.severity === 'error'); const preview = summarizeNode(node); return (
{/* type-colored left rail, ties the list row to the canvas hue */} {expanded && (
{!isEntry && ( )}
{issues.length > 0 && (
{issues.map((i, ix) => ( ))}
)}
)}
); } // ============================================================ // Per-node-type config form — wraps the extracted dispatcher with // the list-view's "Show advanced" disclosure (which exposes the // internal node_key for stable analytics, hidden by default). // ============================================================ function NodeConfigWithAdvanced({ node, allNodes, onUpdate, onUpdateConfig, }: { node: BuilderNode; allNodes: BuilderNode[]; onUpdate: (patch: Partial) => void; onUpdateConfig: (patch: Record) => void; }) { const [showAdvanced, setShowAdvanced] = useState(false); const hasReplyIds = node.node_type === 'send_buttons' || node.node_type === 'send_list'; return (
{showAdvanced && (
onUpdate({ node_key: slugify(e.target.value, node.node_key) }) } className="bg-muted font-mono text-xs" />
{hasReplyIds && (

Reply IDs for each option are shown inline above. They're returned by WhatsApp when a customer taps; you usually don't need to touch them.

)}
)}
); } // ============================================================ // Add-node menu // ============================================================ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { const types: NodeType[] = [ 'start', 'send_buttons', 'send_list', 'send_message', 'send_media', 'collect_input', 'condition', 'set_tag', 'handoff', 'end', ]; return ( Add node {groupNodeTypesByCategory(types).map((group, i) => (
{i > 0 && } {group.label} {group.types.map((t) => { const meta = NODE_META[t]; return ( onAdd(t)}> {meta.label} ); })}
))}
); }