mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Reworks the flow editor (/flows/[id]) to the Flow Builder design handoff, adapted to wacrm's existing tokens, components, and light+dark theming. Keeps all behaviour — React Flow canvas, drag-to-connect, typed config forms, validation, save/activate/delete, per-browser view persistence, mobile-forces-list. - shared.tsx: per-node-type color system (nodeColors) + one-line blurbs, and a shared NodeIconChip component. Label color blends toward --foreground via color-mix so it stays readable in both modes. - header.tsx: compact single-row toolbar (back / icon / inline name / status chip / edited dot · Runs / Delete / Activate / Save) plus a subtle inline description note. - flow-editor-shell.tsx: app-like full-height layout (toolbar → mode row → stage → validation bar) with a segmented Canvas/List control and a complete node-type legend derived from NODE_META. - flow-canvas.tsx: per-type colored node cards, branch ports, dot-grid background, rounded controls/minimap, top-left primary Add-node menu, colored inspector header. - flow-builder.tsx: list rows tied to the same per-type color language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
"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,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { cn } from "@/lib/utils";
|
|
import { type ValidationIssue } from "@/lib/flows/validate";
|
|
import {
|
|
NODE_META,
|
|
NodeIconChip,
|
|
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<string, unknown>) 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<Set<string>>(
|
|
() => new Set(state.nodes.map((n) => n.node_key)),
|
|
);
|
|
const nodeRefs = useRef<Map<string, HTMLDivElement>>(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 (
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
|
|
<TriggerPanel
|
|
state={state}
|
|
setState={setState}
|
|
triggerIssues={issues.filter((i) => i.scope === "trigger")}
|
|
/>
|
|
|
|
<EntryPicker state={state} setState={setState} />
|
|
|
|
<section className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-foreground">
|
|
Nodes ({state.nodes.length})
|
|
</h2>
|
|
<AddNodeButton onAdd={addNode} />
|
|
</div>
|
|
|
|
{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">
|
|
Add a <strong>Start</strong> node, then a <strong>Send buttons</strong>
|
|
{" "}node, then a <strong>Handoff</strong> — that's the welcome-menu
|
|
shape from the brief.
|
|
</div>
|
|
) : (
|
|
state.nodes.map((node) => (
|
|
<NodeCard
|
|
key={node.node_key}
|
|
node={node}
|
|
allNodes={state.nodes}
|
|
expanded={expandedWithFlash.has(node.node_key)}
|
|
isEntry={state.entry_node_id === node.node_key}
|
|
isFlashed={flashKey === node.node_key}
|
|
cardRef={setNodeRef(node.node_key)}
|
|
issues={issues.filter(
|
|
(i) => 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 }))
|
|
}
|
|
/>
|
|
))
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 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 (
|
|
<Input
|
|
value={draft}
|
|
onChange={(e) => 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<React.SetStateAction<BuilderState>>;
|
|
triggerIssues: ValidationIssue[];
|
|
}) {
|
|
return (
|
|
<section className="rounded-lg border border-border bg-card p-4">
|
|
<h2 className="mb-3 text-sm font-semibold text-foreground">Trigger</h2>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">When…</label>
|
|
<Select
|
|
value={state.trigger_type}
|
|
onValueChange={(v) =>
|
|
setState((s) => ({
|
|
...s,
|
|
trigger_type: v as BuilderState["trigger_type"],
|
|
trigger_config:
|
|
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {},
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger className="bg-muted">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="keyword">
|
|
A message contains a keyword
|
|
</SelectItem>
|
|
<SelectItem value="first_inbound_message">
|
|
Customer's first ever inbound message
|
|
</SelectItem>
|
|
<SelectItem value="manual">
|
|
Manual only (no auto-trigger)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{state.trigger_type === "keyword" && (
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">
|
|
Keywords (comma-separated)
|
|
</label>
|
|
<KeywordsInput
|
|
keywords={
|
|
Array.isArray(state.trigger_config.keywords)
|
|
? (state.trigger_config.keywords as string[])
|
|
: []
|
|
}
|
|
onChange={(keywords) =>
|
|
setState((s) => ({
|
|
...s,
|
|
trigger_config: { ...s.trigger_config, keywords },
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{triggerIssues.length > 0 && (
|
|
<div className="mt-3 flex flex-col gap-1">
|
|
{triggerIssues.map((i, ix) => (
|
|
<IssueLine key={ix} issue={i} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// Entry-node picker
|
|
// ============================================================
|
|
|
|
function EntryPicker({
|
|
state,
|
|
setState,
|
|
}: {
|
|
state: BuilderState;
|
|
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
|
}) {
|
|
if (state.nodes.length === 0) return null;
|
|
return (
|
|
<section className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
|
|
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
|
|
<span className="text-xs text-muted-foreground">Entry node:</span>
|
|
<NodeKeySelect
|
|
value={state.entry_node_id}
|
|
nodes={state.nodes}
|
|
onChange={(key) =>
|
|
setState((s) => ({ ...s, entry_node_id: key }))
|
|
}
|
|
placeholder="Pick the first node…"
|
|
className="flex-1 max-w-xs"
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 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<BuilderNode>) => void;
|
|
onUpdateConfig: (patch: Record<string, unknown>) => 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 (
|
|
<div
|
|
ref={cardRef}
|
|
className={cn(
|
|
"relative overflow-hidden rounded-xl border bg-card transition-shadow duration-500",
|
|
hasError
|
|
? "border-red-500/40"
|
|
: isEntry
|
|
? "border-primary/50"
|
|
: "border-border",
|
|
isFlashed &&
|
|
"ring-2 ring-primary ring-offset-2 ring-offset-background",
|
|
)}
|
|
>
|
|
{/* type-colored left rail, ties the list row to the canvas hue */}
|
|
<span
|
|
className="absolute inset-y-0 left-0 w-[3px]"
|
|
style={{ background: c.solid }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
|
|
>
|
|
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="truncate text-[11px] font-semibold uppercase tracking-wider"
|
|
style={{ color: c.text }}
|
|
>
|
|
{meta.label}
|
|
</span>
|
|
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
{node.node_key}
|
|
</code>
|
|
{isEntry && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
|
|
>
|
|
Entry
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{!expanded && preview && (
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
{preview}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{hasError && (
|
|
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
|
)}
|
|
{expanded ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
{expanded && (
|
|
<div className="border-t border-border px-4 py-4">
|
|
<NodeConfigWithAdvanced
|
|
node={node}
|
|
allNodes={allNodes}
|
|
onUpdate={onUpdate}
|
|
onUpdateConfig={onUpdateConfig}
|
|
/>
|
|
<div className="mt-4 flex items-center justify-between border-t border-border pt-3">
|
|
<div className="flex items-center gap-2">
|
|
{!isEntry && (
|
|
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
|
Set as entry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onRemove}
|
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Remove node
|
|
</Button>
|
|
</div>
|
|
{issues.length > 0 && (
|
|
<div className="mt-3 flex flex-col gap-1 rounded-md bg-red-500/5 p-2">
|
|
{issues.map((i, ix) => (
|
|
<IssueLine key={ix} issue={i} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 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<BuilderNode>) => void;
|
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
|
}) {
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const hasReplyIds =
|
|
node.node_type === "send_buttons" || node.node_type === "send_list";
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<NodeConfigForm
|
|
node={node}
|
|
allNodes={allNodes}
|
|
showAdvanced={showAdvanced}
|
|
onUpdateConfig={onUpdateConfig}
|
|
/>
|
|
<div className="border-t border-border pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced((v) => !v)}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showAdvanced ? (
|
|
<ChevronUp className="h-3 w-3" />
|
|
) : (
|
|
<ChevronDown className="h-3 w-3" />
|
|
)}
|
|
{showAdvanced ? "Hide" : "Show"} advanced
|
|
</button>
|
|
{showAdvanced && (
|
|
<div className="mt-3 flex flex-col gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">
|
|
Node key (internal identifier — keep stable for analytics)
|
|
</label>
|
|
<Input
|
|
value={node.node_key}
|
|
onChange={(e) =>
|
|
onUpdate({ node_key: slugify(e.target.value, node.node_key) })
|
|
}
|
|
className="bg-muted font-mono text-xs"
|
|
/>
|
|
</div>
|
|
{hasReplyIds && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
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.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 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 (
|
|
<DropdownMenu>
|
|
<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"
|
|
aria-label="Add node"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add node
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="border-border bg-popover">
|
|
{types.map((t) => {
|
|
const meta = NODE_META[t];
|
|
return (
|
|
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
|
|
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} />
|
|
{meta.label}
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|