mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #318 from ArnasDon/feat/flows-grouped-add-menu
feat(flows): group the add-step menu by node category
This commit is contained in:
commit
30cc7a6ab2
@ -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's the welcome-menu
|
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
|
||||||
shape from the brief.
|
— that'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're
|
Reply IDs for each option are shown inline above. They're
|
||||||
returned by WhatsApp when a customer taps; you usually don't
|
returned by WhatsApp when a customer taps; you usually
|
||||||
need to touch them.
|
don'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) => (
|
||||||
const meta = NODE_META[t];
|
<div key={group.id}>
|
||||||
return (
|
{i > 0 && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
|
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
|
||||||
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} />
|
{group.label}
|
||||||
{meta.label}
|
</DropdownMenuLabel>
|
||||||
</DropdownMenuItem>
|
{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.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,31 +734,41 @@ 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">
|
||||||
const meta = NODE_META[t];
|
{group.label}
|
||||||
return (
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
{group.types.map((t) => {
|
||||||
key={t}
|
const meta = NODE_META[t];
|
||||||
onClick={() => handleAdd(t)}
|
return (
|
||||||
className="gap-3 py-2"
|
<DropdownMenuItem
|
||||||
>
|
key={t}
|
||||||
<NodeIconChip type={t} size={28} iconSize={16} className="rounded-md" />
|
onClick={() => handleAdd(t)}
|
||||||
<span className="flex flex-col">
|
className="gap-3 py-2"
|
||||||
<span className="text-[13px] font-semibold text-popover-foreground">
|
>
|
||||||
{meta.label}
|
<NodeIconChip
|
||||||
</span>
|
type={t}
|
||||||
<span className="text-[11.5px] text-muted-foreground">
|
size={28}
|
||||||
{meta.blurb}
|
iconSize={16}
|
||||||
</span>
|
className="rounded-md"
|
||||||
</span>
|
/>
|
||||||
</DropdownMenuItem>
|
<span className="flex flex-col">
|
||||||
);
|
<span className="text-popover-foreground text-[13px] font-semibold">
|
||||||
})}
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[11.5px]">
|
||||||
|
{meta.blurb}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user