wacrm/src/components/flows/shared.tsx

429 lines
15 KiB
TypeScript

/**
* Shared editor primitives used by both the linear-list and canvas
* views of a flow.
*
* What lives here vs in flow-builder.tsx / flow-canvas.tsx:
* - Types and metadata that BOTH views need to render a node
* consistently (icon, label, color, 1-line summary).
* - Editing-only helpers (defaultConfigFor, slugify, uniqueNodeKey,
* BuilderState) stay in flow-builder.tsx until the canvas grows
* editing affordances — pulled across in the PR that adds them.
*
* Why .tsx and not .ts: NODE_META holds lucide icon components, which
* are typed as React components; importing them from a .ts module
* works at runtime but trips TypeScript's
* `verbatimModuleSyntax`-related linting in some setups. Keeping the
* file .tsx future-proofs it for inline JSX in node-card renderers.
*/
import {
Flag,
GitFork,
Inbox,
ListChecks,
ListPlus,
MessageCircle,
Paperclip,
PlayCircle,
Tag,
UserPlus,
Workflow,
} from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================
// Node-type union — single source of truth for every place the UI
// enumerates types (add menu, type pickers, switch statements). Kept
// in lockstep with `FlowNodeType` in src/lib/flows/types.ts (which
// drives the engine's exhaustiveness check); a divergence between the
// two is always a bug.
// ============================================================
export type NodeType =
| 'start'
| 'send_message'
| 'send_buttons'
| 'send_list'
| 'send_media'
| 'collect_input'
| 'condition'
| 'set_tag'
| 'handoff'
| 'end';
export interface BuilderNode {
node_key: string;
node_type: NodeType;
config: Record<string, unknown>;
/** Optional in v1 — defaults to 0 in the DB. Canvas view reads it
* to position nodes; list view ignores it. */
position_x?: number;
position_y?: number;
}
// ============================================================
// Per-node-type metadata used to render icons + labels everywhere
// 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;
category: NodeCategory;
}
> = {
start: {
label: 'Start',
icon: PlayCircle,
color: 'text-emerald-400',
blurb: 'Entry point of the flow',
category: 'flow',
},
send_message: {
label: 'Send message',
icon: MessageCircle,
color: 'text-sky-400',
blurb: 'Sends a WhatsApp text message',
category: 'messaging',
},
send_buttons: {
label: 'Send buttons',
icon: ListChecks,
color: 'text-primary',
blurb: 'Sends quick-reply buttons',
category: 'messaging',
},
send_list: {
label: 'Send list',
icon: ListPlus,
color: 'text-indigo-400',
blurb: 'Sends a tappable list of options',
category: 'messaging',
},
send_media: {
label: 'Send media',
icon: Paperclip,
color: 'text-cyan-400',
blurb: 'Sends an image, video, or document',
category: 'messaging',
},
collect_input: {
label: 'Collect input',
icon: Inbox,
color: 'text-teal-400',
blurb: 'Asks a question, saves the reply',
category: 'logic',
},
condition: {
label: 'If / else',
icon: GitFork,
color: 'text-fuchsia-400',
blurb: 'Branches on a rule',
category: 'logic',
},
set_tag: {
label: 'Tag contact',
icon: Tag,
color: 'text-pink-400',
blurb: 'Adds or removes a contact tag',
category: 'logic',
},
handoff: {
label: 'Handoff to agent',
icon: UserPlus,
color: 'text-amber-400',
blurb: 'Hands the conversation to a human',
category: 'flow',
},
end: {
label: 'End',
icon: Flag,
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.
//
// Each node type gets its own hue so the canvas reads at a glance —
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
// so a node card can tint its icon chip, type label, selection ring,
// and edge ports from one source, the way the Flow Builder design
// handoff does. Hues sit in the same oklch family as the app tokens
// in globals.css; they don't replace --primary (the accent), they
// complement it. `nodeColors()` derives the soft/ring/text variants.
// ============================================================
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
};
export interface NodeColors {
/** Full-strength hue — icon glyph, selection ring, port fill. */
solid: string;
/** ~14% tint — icon chip background, soft fills. */
soft: string;
/** ~45% tint — hover border / focus ring. */
ring: string;
/** Hue for the uppercase type label, kept readable in BOTH modes. */
text: string;
}
export function nodeColors(type: NodeType): NodeColors {
const t = NODE_HUE[type];
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
return {
solid,
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
// Blend the hue toward the live --foreground token so the label
// holds contrast in BOTH modes: in dark mode --foreground is
// near-white (the label lightens to read on the dark card), in
// light mode it's near-black (the label darkens to read on the
// white card). The old fixed-light value only worked on dark.
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
};
}
// ============================================================
// Shared node icon chip — the per-type colored glyph badge used in
// the canvas node card, list-view card, inspector header, and the
// add-step menu. One component so a styling change (radius, contrast,
// hover) lands in every place at once and the `nodeColors()` lookup
// lives in exactly one spot.
// ============================================================
export function NodeIconChip({
type,
size = 24,
iconSize = 14,
className,
}: {
type: NodeType;
/** Chip side length in px. */
size?: number;
/** Glyph side length in px. */
iconSize?: number;
className?: string;
}) {
const meta = NODE_META[type];
const c = nodeColors(type);
const Icon = meta.icon;
return (
<span
className={cn(
'flex shrink-0 items-center justify-center rounded-lg',
className
)}
style={{ width: size, height: size, background: c.soft, color: c.solid }}
>
<Icon size={iconSize} />
</span>
);
}
// ============================================================
// Pure editing helpers — used by forms in both views.
// ============================================================
/**
* Coerce an arbitrary string into a stable identifier (node_key,
* reply_id, etc.). Lowercases, collapses non-alphanumerics into
* single underscores, and trims leading/trailing underscores. Falls
* back to `fallback` for inputs that reduce to an empty string.
*/
export function slugify(s: string, fallback: string): string {
const cleaned = s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return cleaned || fallback;
}
// ============================================================
// Summary helpers — short, single-line content previews used in
// collapsed node cards (list view) and node tiles (canvas view).
// Returns null when there's nothing meaningful to show (start/end,
// or a freshly-added node with no fields filled in).
// ============================================================
export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + '…';
}
export function summarizeNode(
node: BuilderNode,
t?: (key: string, values?: Record<string, string | number>) => string
): string | null {
const cfg = node.config;
switch (node.node_type) {
case 'start':
case 'end':
return null;
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 : '';
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean)
.join(' / ');
if (text.length > 0) {
return titles
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
: truncate(text);
}
return titles || null;
}
case 'send_list': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
const rowCount = sections.reduce<number>((sum, s) => {
const rows = Array.isArray(s.rows) ? s.rows : [];
return sum + rows.length;
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${t ? t('options', { count: rowCount }) : `${rowCount} option${rowCount === 1 ? '' : 's'}`}`
: truncate(text);
}
return rowCount > 0
? t
? t('optionsAcrossSections', { rowCount, sectionCount: sections.length })
: `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null;
}
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 : '';
const label = mediaType
? t ? t(mediaType) || (mediaType.charAt(0).toUpperCase() + mediaType.slice(1)) : mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: t ? t('media') : 'Media';
if (!url) return t ? t('noFile', { label }) : `${label} (no file uploaded)`;
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 : '';
if (prompt.length > 0) {
return varKey
? `${truncate(prompt, 50)} → vars.${varKey}`
: truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case 'condition': {
const subjectKey =
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null;
const subject =
cfg.subject === 'tag'
? 'tag'
: cfg.subject === 'contact_field'
? 'field'
: 'var';
const subjectStr =
subject === 'tag'
? t ? t('hasTag', { tag: truncate(subjectKey, 24) }) : `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op =
cfg.operator === 'equals'
? '=='
: cfg.operator === 'contains'
? t ? t('opContains') : 'contains'
: cfg.operator === 'present'
? t ? t('opExists') : 'exists'
: cfg.operator === 'absent'
? t ? t('opMissing') : 'missing'
: '';
const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr =
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"`
: '';
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case 'set_tag': {
const mode = cfg.mode === 'remove' ? (t ? t('modeRemove') : 'Remove') : (t ? t('modeAdd') : '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
? t ? t('tagPicked', { mode, tag: tagId.slice(0, 8) }) : `${mode} tag ${tagId.slice(0, 8)}…`
: t ? t('tagNone', { mode }) : `${mode} tag (none picked)`;
}
case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null;
}
}
}