mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
39aeac8c0c
commit
ce0d04ba80
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Linear-list flow editor.
|
||||
@ -17,7 +17,7 @@
|
||||
* 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 {
|
||||
CircleAlert,
|
||||
Plus,
|
||||
@ -25,41 +25,41 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
} 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";
|
||||
} 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";
|
||||
} 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";
|
||||
} 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
|
||||
@ -87,7 +87,7 @@ export function FlowBuilder() {
|
||||
// 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)),
|
||||
() => new Set(state.nodes.map((n) => n.node_key))
|
||||
);
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
@ -99,7 +99,7 @@ export function FlowBuilder() {
|
||||
const key = addNodeCtx(type);
|
||||
setExpanded((prev) => new Set([...prev, key]));
|
||||
},
|
||||
[addNodeCtx],
|
||||
[addNodeCtx]
|
||||
);
|
||||
|
||||
const removeNode = useCallback(
|
||||
@ -111,7 +111,7 @@ export function FlowBuilder() {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[removeNodeCtx],
|
||||
[removeNodeCtx]
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback((key: string) => {
|
||||
@ -128,7 +128,7 @@ export function FlowBuilder() {
|
||||
if (el) nodeRefs.current.set(key, el);
|
||||
else nodeRefs.current.delete(key);
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// React to validator jumps via the shared flashKey. We DERIVE the
|
||||
@ -147,7 +147,7 @@ export function FlowBuilder() {
|
||||
// committed any expand-induced layout shift.
|
||||
requestAnimationFrame(() => {
|
||||
const el = nodeRefs.current.get(flashKey);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}, [flashKey]);
|
||||
|
||||
@ -156,24 +156,24 @@ export function FlowBuilder() {
|
||||
<TriggerPanel
|
||||
state={state}
|
||||
setState={setState}
|
||||
triggerIssues={issues.filter((i) => i.scope === "trigger")}
|
||||
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">
|
||||
<h2 className="text-foreground text-sm font-semibold">
|
||||
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 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> node, then a <strong>Handoff</strong>{' '}
|
||||
— that's the welcome-menu shape from the brief.
|
||||
</div>
|
||||
) : (
|
||||
state.nodes.map((node) => (
|
||||
@ -186,7 +186,7 @@ export function FlowBuilder() {
|
||||
isFlashed={flashKey === node.node_key}
|
||||
cardRef={setNodeRef(node.node_key)}
|
||||
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)}
|
||||
onUpdate={(patch) => updateNode(node.node_key, patch)}
|
||||
@ -203,7 +203,6 @@ export function FlowBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Keyword trigger input
|
||||
// ============================================================
|
||||
@ -225,14 +224,14 @@ function KeywordsInput({
|
||||
keywords: string[];
|
||||
onChange: (keywords: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(keywords.join(", "));
|
||||
const [draft, setDraft] = useState(keywords.join(', '));
|
||||
|
||||
function commit() {
|
||||
const parsed = draft
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
setDraft(parsed.join(", "));
|
||||
setDraft(parsed.join(', '));
|
||||
onChange(parsed);
|
||||
}
|
||||
|
||||
@ -242,7 +241,7 @@ function KeywordsInput({
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@ -267,19 +266,21 @@ function TriggerPanel({
|
||||
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>
|
||||
<section className="border-border bg-card rounded-lg border p-4">
|
||||
<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>
|
||||
<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
|
||||
value={state.trigger_type}
|
||||
onValueChange={(v) =>
|
||||
setState((s) => ({
|
||||
...s,
|
||||
trigger_type: v as BuilderState["trigger_type"],
|
||||
trigger_type: v as BuilderState['trigger_type'],
|
||||
trigger_config:
|
||||
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {},
|
||||
v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
|
||||
}))
|
||||
}
|
||||
>
|
||||
@ -299,9 +300,9 @@ function TriggerPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{state.trigger_type === "keyword" && (
|
||||
{state.trigger_type === 'keyword' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<KeywordsInput
|
||||
@ -344,17 +345,15 @@ function EntryPicker({
|
||||
}) {
|
||||
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>
|
||||
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
|
||||
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">Entry node:</span>
|
||||
<NodeKeySelect
|
||||
value={state.entry_node_id}
|
||||
nodes={state.nodes}
|
||||
onChange={(key) =>
|
||||
setState((s) => ({ ...s, entry_node_id: key }))
|
||||
}
|
||||
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
|
||||
placeholder="Pick the first node…"
|
||||
className="flex-1 max-w-xs"
|
||||
className="max-w-xs flex-1"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@ -393,20 +392,19 @@ function NodeCard({
|
||||
}) {
|
||||
const meta = NODE_META[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);
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
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
|
||||
? "border-red-500/40"
|
||||
? 'border-red-500/40'
|
||||
: isEntry
|
||||
? "border-primary/50"
|
||||
: "border-border",
|
||||
isFlashed &&
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
? 'border-primary/50'
|
||||
: 'border-border',
|
||||
isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{/* 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="flex items-center gap-2">
|
||||
<span
|
||||
className="truncate text-[11px] font-semibold uppercase tracking-wider"
|
||||
className="truncate text-[11px] font-semibold tracking-wider uppercase"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{meta.label}
|
||||
</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}
|
||||
</code>
|
||||
{isEntry && (
|
||||
<Badge
|
||||
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
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!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}
|
||||
</p>
|
||||
)}
|
||||
@ -450,20 +448,20 @@ function NodeCard({
|
||||
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
)}
|
||||
{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>
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 py-4">
|
||||
<div className="border-border border-t 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="border-border mt-4 flex items-center justify-between border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEntry && (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
@ -513,7 +511,7 @@ function NodeConfigWithAdvanced({
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const hasReplyIds =
|
||||
node.node_type === "send_buttons" || node.node_type === "send_list";
|
||||
node.node_type === 'send_buttons' || node.node_type === 'send_list';
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<NodeConfigForm
|
||||
@ -522,23 +520,23 @@ function NodeConfigWithAdvanced({
|
||||
showAdvanced={showAdvanced}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
<div className="border-t border-border pt-3">
|
||||
<div className="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
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 ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{showAdvanced ? "Hide" : "Show"} advanced
|
||||
{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">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Node key (internal identifier — keep stable for analytics)
|
||||
</label>
|
||||
<Input
|
||||
@ -550,10 +548,10 @@ function NodeConfigWithAdvanced({
|
||||
/>
|
||||
</div>
|
||||
{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're
|
||||
returned by WhatsApp when a customer taps; you usually don't
|
||||
need to touch them.
|
||||
returned by WhatsApp when a customer taps; you usually
|
||||
don't need to touch them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -563,45 +561,51 @@ function NodeConfigWithAdvanced({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 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",
|
||||
'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"
|
||||
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"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add node
|
||||
</DropdownMenuTrigger>
|
||||
<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];
|
||||
return (
|
||||
<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}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Canvas / mind-map view of a flow. Editable, in parity with the
|
||||
@ -36,7 +36,7 @@
|
||||
* list view reads.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
@ -55,12 +55,12 @@ import {
|
||||
type NodeChange,
|
||||
type NodeProps,
|
||||
type OnNodeDrag,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -68,29 +68,32 @@ import {
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
applyEdgeConnection,
|
||||
deriveCanvasEdges,
|
||||
outgoingSlots,
|
||||
} from "@/lib/flows/edges";
|
||||
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
||||
} from '@/lib/flows/edges';
|
||||
import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
|
||||
import {
|
||||
NODE_META,
|
||||
NodeIconChip,
|
||||
groupNodeTypesByCategory,
|
||||
nodeColors,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
type NodeType,
|
||||
} from "./shared";
|
||||
} from './shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFlowEditor } from "./flow-editor-state";
|
||||
import { NodeConfigForm } from "./forms/node-config-form";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFlowEditor } from './flow-editor-state';
|
||||
import { NodeConfigForm } from './forms/node-config-form';
|
||||
|
||||
// React-Flow node `data` payload — the bits our custom renderer needs.
|
||||
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
|
||||
// NODE_HUE — a palette tweak there can't leave these stale.
|
||||
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
|
||||
if (nodeType === "condition" && slotId === "true") {
|
||||
return nodeColors("start").solid;
|
||||
if (nodeType === 'condition' && slotId === 'true') {
|
||||
return nodeColors('start').solid;
|
||||
}
|
||||
if (nodeType === "condition" && slotId === "false") {
|
||||
return nodeColors("handoff").solid;
|
||||
if (nodeType === 'condition' && slotId === 'false') {
|
||||
return nodeColors('handoff').solid;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@ -137,7 +140,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
// don't need an incoming Handle. Every other node type accepts
|
||||
// incoming edges (including terminal handoff / end — they're the
|
||||
// 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
|
||||
// edge of the card. Multi-slot nodes (condition, send_buttons,
|
||||
// send_list) render slot rows inline so each handle visually sits
|
||||
@ -147,10 +150,10 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--nc": c.solid,
|
||||
"--nc-soft": c.soft,
|
||||
"--nc-ring": c.ring,
|
||||
"--nc-text": c.text,
|
||||
'--nc': c.solid,
|
||||
'--nc-soft': c.soft,
|
||||
'--nc-ring': c.ring,
|
||||
'--nc-text': c.text,
|
||||
borderColor: selected ? c.solid : undefined,
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
|
||||
@ -158,21 +161,21 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
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
|
||||
? "border-[var(--nc)]"
|
||||
: "border-border hover:border-[var(--nc-ring)]",
|
||||
? 'border-[var(--nc)]'
|
||||
: 'border-border hover:border-[var(--nc-ring)]',
|
||||
// Flash overrides hover/selected colors briefly. Tailwind's
|
||||
// built-in `animate-pulse` is too gentle; a ring with the
|
||||
// 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 && (
|
||||
<Handle
|
||||
type="target"
|
||||
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"
|
||||
/>
|
||||
<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 }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
</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}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<div
|
||||
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}>
|
||||
{slot.label}
|
||||
@ -225,7 +228,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
// sits flush with the right edge of the card instead
|
||||
// of floating at vertical center. The negative offset
|
||||
// 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>
|
||||
))}
|
||||
@ -238,7 +241,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
id={slots[0].id}
|
||||
position={Position.Right}
|
||||
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>
|
||||
@ -286,9 +289,9 @@ function FlowCanvasInner() {
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
selectedNodeKey
|
||||
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
|
||||
? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
|
||||
: null,
|
||||
[selectedNodeKey, builderNodes],
|
||||
[selectedNodeKey, builderNodes]
|
||||
);
|
||||
|
||||
const autoLayoutPositions = useMemo(() => {
|
||||
@ -302,7 +305,7 @@ function FlowCanvasInner() {
|
||||
height: NODE_HEIGHT,
|
||||
})),
|
||||
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
|
||||
{ direction: "TB" },
|
||||
{ direction: 'TB' }
|
||||
)
|
||||
: null;
|
||||
}, [builderNodes]);
|
||||
@ -317,8 +320,8 @@ function FlowCanvasInner() {
|
||||
persistedAutoLayoutRef.current = true;
|
||||
updateNodePositions(
|
||||
Object.fromEntries(
|
||||
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
|
||||
),
|
||||
[...autoLayoutPositions].map(([key, pos]) => [key, pos])
|
||||
)
|
||||
);
|
||||
}, [autoLayoutPositions, updateNodePositions]);
|
||||
|
||||
@ -327,7 +330,7 @@ function FlowCanvasInner() {
|
||||
const fallback = autoLayoutPositions?.get(n.node_key);
|
||||
return {
|
||||
id: n.node_key,
|
||||
type: "flow",
|
||||
type: 'flow',
|
||||
position: {
|
||||
x: fallback?.x ?? n.position_x ?? 0,
|
||||
y: fallback?.y ?? n.position_y ?? 0,
|
||||
@ -362,11 +365,11 @@ function FlowCanvasInner() {
|
||||
sourceHandle: e.sourceHandle,
|
||||
label: e.label,
|
||||
// Mode-aware via CSS tokens so edge chrome flips with light/dark.
|
||||
labelStyle: { fill: "var(--muted-foreground)", fontSize: 11 },
|
||||
labelBgStyle: { fill: "var(--card)" },
|
||||
labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
|
||||
labelBgStyle: { fill: 'var(--card)' },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: { stroke: "var(--border)", strokeWidth: 1.5 },
|
||||
style: { stroke: 'var(--border)', strokeWidth: 1.5 },
|
||||
}));
|
||||
|
||||
return rfEdges;
|
||||
@ -376,7 +379,7 @@ function FlowCanvasInner() {
|
||||
(changes: NodeChange<RfNode<NodeData>>[]) => {
|
||||
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Drag-to-position: React-Flow tracks the visual drag internally and
|
||||
@ -390,7 +393,7 @@ function FlowCanvasInner() {
|
||||
(_event, node) => {
|
||||
updateNodePosition(node.id, node.position.x, node.position.y);
|
||||
},
|
||||
[updateNodePosition],
|
||||
[updateNodePosition]
|
||||
);
|
||||
|
||||
// Pan to the flashed node when the validator panel requests one.
|
||||
@ -412,7 +415,7 @@ function FlowCanvasInner() {
|
||||
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
|
||||
setSelectedNodeKey(node.id);
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// 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.
|
||||
const handleConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target || !connection.sourceHandle) {
|
||||
if (
|
||||
!connection.source ||
|
||||
!connection.target ||
|
||||
!connection.sourceHandle
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sourceNode = builderNodes.find(
|
||||
(n) => n.node_key === connection.source,
|
||||
(n) => n.node_key === connection.source
|
||||
);
|
||||
if (!sourceNode) return;
|
||||
// Self-loops are a footgun (a button whose target is its own
|
||||
@ -437,11 +444,11 @@ function FlowCanvasInner() {
|
||||
const patch = applyEdgeConnection(
|
||||
sourceNode,
|
||||
connection.sourceHandle,
|
||||
connection.target,
|
||||
connection.target
|
||||
);
|
||||
if (patch) updateNodeConfig(connection.source, patch);
|
||||
},
|
||||
[builderNodes, updateNodeConfig],
|
||||
[builderNodes, updateNodeConfig]
|
||||
);
|
||||
|
||||
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
|
||||
@ -457,7 +464,7 @@ function FlowCanvasInner() {
|
||||
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
|
||||
}
|
||||
},
|
||||
[removeNode, selectedNodeKey],
|
||||
[removeNode, selectedNodeKey]
|
||||
);
|
||||
|
||||
// Edge delete: clear the source node's slot rather than removing
|
||||
@ -469,11 +476,11 @@ function FlowCanvasInner() {
|
||||
if (!e.sourceHandle) continue;
|
||||
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
|
||||
if (!sourceNode) continue;
|
||||
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
|
||||
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
|
||||
if (patch) updateNodeConfig(e.source, patch);
|
||||
}
|
||||
},
|
||||
[builderNodes, updateNodeConfig],
|
||||
[builderNodes, updateNodeConfig]
|
||||
);
|
||||
|
||||
// Wrapped mutators that target the currently-selected node — pass to
|
||||
@ -483,7 +490,7 @@ function FlowCanvasInner() {
|
||||
(patch: Record<string, unknown>) => {
|
||||
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
|
||||
},
|
||||
[selectedNodeKey, updateNodeConfig],
|
||||
[selectedNodeKey, updateNodeConfig]
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
@ -499,7 +506,7 @@ function FlowCanvasInner() {
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
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>
|
||||
<CanvasAddNodeButton />
|
||||
</div>
|
||||
@ -524,7 +531,7 @@ function FlowCanvasInner() {
|
||||
onEdgesDelete={handleEdgesDelete}
|
||||
// Default is "Backspace" only — accept both so Mac users
|
||||
// hitting Delete (Fn+Backspace) get the same behavior.
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
nodesConnectable={true}
|
||||
edgesFocusable={true}
|
||||
elementsSelectable={true}
|
||||
@ -542,7 +549,7 @@ function FlowCanvasInner() {
|
||||
color="var(--border)"
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<MiniMap
|
||||
@ -554,9 +561,9 @@ function FlowCanvasInner() {
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
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 />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
@ -614,24 +621,24 @@ function NodeEditSheet({
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
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} />
|
||||
<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>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-0.5 text-xs text-muted-foreground">
|
||||
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.blurb}
|
||||
</SheetDescription>
|
||||
</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}
|
||||
</code>
|
||||
</SheetHeader>
|
||||
@ -645,7 +652,7 @@ function NodeEditSheet({
|
||||
/>
|
||||
</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 ? (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
Set as entry
|
||||
@ -676,16 +683,16 @@ function NodeEditSheet({
|
||||
// ============================================================
|
||||
|
||||
const ADD_NODE_TYPES: NodeType[] = [
|
||||
"start",
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"send_message",
|
||||
"send_media",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"end",
|
||||
'start',
|
||||
'send_buttons',
|
||||
'send_list',
|
||||
'send_message',
|
||||
'send_media',
|
||||
'collect_input',
|
||||
'condition',
|
||||
'set_tag',
|
||||
'handoff',
|
||||
'end',
|
||||
];
|
||||
|
||||
function CanvasAddNodeButton() {
|
||||
@ -699,7 +706,7 @@ function CanvasAddNodeButton() {
|
||||
// .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
|
||||
// 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;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const center = reactFlow.screenToFlowPosition({
|
||||
@ -709,13 +716,17 @@ function CanvasAddNodeButton() {
|
||||
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
|
||||
// so the card sits visually centered rather than top-left at the
|
||||
// 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 (
|
||||
<DropdownMenu>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -723,12 +734,15 @@ function CanvasAddNodeButton() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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">
|
||||
Add a step
|
||||
</div>
|
||||
{ADD_NODE_TYPES.map((t) => {
|
||||
{groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
|
||||
<div key={group.id}>
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<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];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@ -736,18 +750,25 @@ function CanvasAddNodeButton() {
|
||||
onClick={() => handleAdd(t)}
|
||||
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="text-[13px] font-semibold text-popover-foreground">
|
||||
<span className="text-popover-foreground text-[13px] font-semibold">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[11.5px] text-muted-foreground">
|
||||
<span className="text-muted-foreground text-[11.5px]">
|
||||
{meta.blurb}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
46
src/components/flows/shared.test.ts
Normal file
46
src/components/flows/shared.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@ -28,9 +28,9 @@ import {
|
||||
Tag,
|
||||
UserPlus,
|
||||
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
|
||||
@ -41,16 +41,16 @@ import { cn } from "@/lib/utils";
|
||||
// ============================================================
|
||||
|
||||
export type NodeType =
|
||||
| "start"
|
||||
| "send_message"
|
||||
| "send_buttons"
|
||||
| "send_list"
|
||||
| "send_media"
|
||||
| "collect_input"
|
||||
| "condition"
|
||||
| "set_tag"
|
||||
| "handoff"
|
||||
| "end";
|
||||
| 'start'
|
||||
| 'send_message'
|
||||
| 'send_buttons'
|
||||
| 'send_list'
|
||||
| 'send_media'
|
||||
| 'collect_input'
|
||||
| 'condition'
|
||||
| 'set_tag'
|
||||
| 'handoff'
|
||||
| 'end';
|
||||
|
||||
export interface BuilderNode {
|
||||
node_key: string;
|
||||
@ -67,72 +67,123 @@ export interface BuilderNode {
|
||||
// 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<
|
||||
NodeType,
|
||||
{ label: string; icon: typeof Workflow; color: string; blurb: string }
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Workflow;
|
||||
color: string;
|
||||
blurb: string;
|
||||
category: NodeCategory;
|
||||
}
|
||||
> = {
|
||||
start: {
|
||||
label: "Start",
|
||||
label: 'Start',
|
||||
icon: PlayCircle,
|
||||
color: "text-emerald-400",
|
||||
blurb: "Entry point of the flow",
|
||||
color: 'text-emerald-400',
|
||||
blurb: 'Entry point of the flow',
|
||||
category: 'flow',
|
||||
},
|
||||
send_message: {
|
||||
label: "Send message",
|
||||
label: 'Send message',
|
||||
icon: MessageCircle,
|
||||
color: "text-sky-400",
|
||||
blurb: "Sends a WhatsApp text message",
|
||||
color: 'text-sky-400',
|
||||
blurb: 'Sends a WhatsApp text message',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_buttons: {
|
||||
label: "Send buttons",
|
||||
label: 'Send buttons',
|
||||
icon: ListChecks,
|
||||
color: "text-primary",
|
||||
blurb: "Sends quick-reply buttons",
|
||||
color: 'text-primary',
|
||||
blurb: 'Sends quick-reply buttons',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_list: {
|
||||
label: "Send list",
|
||||
label: 'Send list',
|
||||
icon: ListPlus,
|
||||
color: "text-indigo-400",
|
||||
blurb: "Sends a tappable list of options",
|
||||
color: 'text-indigo-400',
|
||||
blurb: 'Sends a tappable list of options',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_media: {
|
||||
label: "Send media",
|
||||
label: 'Send media',
|
||||
icon: Paperclip,
|
||||
color: "text-cyan-400",
|
||||
blurb: "Sends an image, video, or document",
|
||||
color: 'text-cyan-400',
|
||||
blurb: 'Sends an image, video, or document',
|
||||
category: 'messaging',
|
||||
},
|
||||
collect_input: {
|
||||
label: "Collect input",
|
||||
label: 'Collect input',
|
||||
icon: Inbox,
|
||||
color: "text-teal-400",
|
||||
blurb: "Asks a question, saves the reply",
|
||||
color: 'text-teal-400',
|
||||
blurb: 'Asks a question, saves the reply',
|
||||
category: 'logic',
|
||||
},
|
||||
condition: {
|
||||
label: "If / else",
|
||||
label: 'If / else',
|
||||
icon: GitFork,
|
||||
color: "text-fuchsia-400",
|
||||
blurb: "Branches on a rule",
|
||||
color: 'text-fuchsia-400',
|
||||
blurb: 'Branches on a rule',
|
||||
category: 'logic',
|
||||
},
|
||||
set_tag: {
|
||||
label: "Tag contact",
|
||||
label: 'Tag contact',
|
||||
icon: Tag,
|
||||
color: "text-pink-400",
|
||||
blurb: "Adds or removes a contact tag",
|
||||
color: 'text-pink-400',
|
||||
blurb: 'Adds or removes a contact tag',
|
||||
category: 'logic',
|
||||
},
|
||||
handoff: {
|
||||
label: "Handoff to agent",
|
||||
label: 'Handoff to agent',
|
||||
icon: UserPlus,
|
||||
color: "text-amber-400",
|
||||
blurb: "Hands the conversation to a human",
|
||||
color: 'text-amber-400',
|
||||
blurb: 'Hands the conversation to a human',
|
||||
category: 'flow',
|
||||
},
|
||||
end: {
|
||||
label: "End",
|
||||
label: 'End',
|
||||
icon: Flag,
|
||||
color: "text-muted-foreground",
|
||||
blurb: "Ends the flow",
|
||||
color: 'text-muted-foreground',
|
||||
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.
|
||||
//
|
||||
@ -212,8 +263,8 @@ export function NodeIconChip({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-lg",
|
||||
className,
|
||||
'flex shrink-0 items-center justify-center rounded-lg',
|
||||
className
|
||||
)}
|
||||
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
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
@ -249,37 +300,39 @@ export function slugify(s: string, fallback: string): 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;
|
||||
return clean.slice(0, max - 1) + "…";
|
||||
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":
|
||||
case 'start':
|
||||
case 'end':
|
||||
return null;
|
||||
case "send_message": {
|
||||
const text = typeof cfg.text === "string" ? cfg.text : "";
|
||||
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 : "";
|
||||
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 : ""))
|
||||
.map((b) => (typeof b.title === 'string' ? b.title : ''))
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
.join(' / ');
|
||||
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;
|
||||
}
|
||||
case "send_list": {
|
||||
const text = typeof cfg.text === "string" ? cfg.text : "";
|
||||
case 'send_list': {
|
||||
const text = typeof cfg.text === 'string' ? cfg.text : '';
|
||||
const sections = Array.isArray(cfg.sections)
|
||||
? (cfg.sections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
@ -289,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
|
||||
}, 0);
|
||||
if (text.length > 0) {
|
||||
return rowCount > 0
|
||||
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
|
||||
? `${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"}`
|
||||
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
}
|
||||
case "send_media": {
|
||||
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 : "";
|
||||
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";
|
||||
: 'Media';
|
||||
if (!url) return `${label} (no file uploaded)`;
|
||||
const name = filename || url.split("/").pop() || "file";
|
||||
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 : "";
|
||||
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
|
||||
? `${truncate(prompt, 50)} → vars.${varKey}`
|
||||
: truncate(prompt);
|
||||
}
|
||||
return varKey ? `→ vars.${varKey}` : null;
|
||||
}
|
||||
case "condition": {
|
||||
case 'condition': {
|
||||
const subjectKey =
|
||||
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
|
||||
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
|
||||
if (!subjectKey) return null;
|
||||
const subject =
|
||||
cfg.subject === "tag"
|
||||
? "tag"
|
||||
: cfg.subject === "contact_field"
|
||||
? "field"
|
||||
: "var";
|
||||
cfg.subject === 'tag'
|
||||
? 'tag'
|
||||
: cfg.subject === 'contact_field'
|
||||
? 'field'
|
||||
: 'var';
|
||||
const subjectStr =
|
||||
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
|
||||
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 : "";
|
||||
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
|
||||
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
|
||||
? ` "${truncate(value, 20)}"`
|
||||
: "";
|
||||
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
|
||||
: '';
|
||||
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 : "";
|
||||
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)`;
|
||||
return tagId
|
||||
? `${mode} tag ${tagId.slice(0, 8)}…`
|
||||
: `${mode} tag (none picked)`;
|
||||
}
|
||||
case "handoff": {
|
||||
const note = typeof cfg.note === "string" ? cfg.note : "";
|
||||
case 'handoff': {
|
||||
const note = typeof cfg.note === 'string' ? cfg.note : '';
|
||||
return note.length > 0 ? truncate(note) : null;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user