mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #161 from ArnasDon/feat/flow-canvas-polish
feat(flows): canvas / mind-map view, PR 3 of 3 — polish
This commit is contained in:
commit
1b66431acf
@ -3,39 +3,32 @@
|
||||
/**
|
||||
* Linear-list flow editor.
|
||||
*
|
||||
* The whole flow (header, trigger config, node list, validation panel)
|
||||
* is owned by this single component. State lives client-side as a
|
||||
* single `BuilderState` object; `Save` PUTs the whole structure to
|
||||
* `/api/flows/[id]`; `Activate` hits `/api/flows/[id]/activate`.
|
||||
* Renders the trigger panel, entry-node picker, and the per-node card
|
||||
* list. Header and validation panel are NOT owned here — they live
|
||||
* once in FlowEditorShell so they show in both views (lifted in PR 3
|
||||
* so canvas users can also save + see validator issues).
|
||||
*
|
||||
* Why one big file: keeps the diff between fields + the form code
|
||||
* obvious, matches the existing `automation-builder.tsx` shape, and
|
||||
* sidesteps over-componentization for a UI that will be replaced by a
|
||||
* react-flow canvas in v2 anyway. The node-config sub-forms live in
|
||||
* the same file as small components rather than separate modules.
|
||||
* State lives in the shared `useFlowEditor()` context — toggling
|
||||
* Canvas ⇄ List never loses edits, and a drag on the canvas updates
|
||||
* the same nodes the list view reads.
|
||||
*
|
||||
* What's still local: the `expanded` set (which cards are open) and
|
||||
* the scroll refs used when the validator's flashKey changes — those
|
||||
* are list-only and have no canvas analogue.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CornerDownRight,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -60,6 +53,7 @@ import {
|
||||
} from "./shared";
|
||||
import { NodeConfigForm } from "./forms/node-config-form";
|
||||
import { NodeKeySelect } from "./forms/fields";
|
||||
import { IssueLine } from "./validation-panel";
|
||||
import {
|
||||
useFlowEditor,
|
||||
type BuilderState,
|
||||
@ -76,34 +70,24 @@ import {
|
||||
// ============================================================
|
||||
|
||||
export function FlowBuilder() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
flow,
|
||||
state,
|
||||
setState,
|
||||
dirty,
|
||||
saving,
|
||||
activating,
|
||||
issues,
|
||||
canActivate,
|
||||
flashKey,
|
||||
addNode: addNodeCtx,
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
removeNode: removeNodeCtx,
|
||||
save: handleSave,
|
||||
setStatus: handleStatus,
|
||||
deleteFlow: handleDelete,
|
||||
} = useFlowEditor();
|
||||
|
||||
// List-only UI state: which cards are expanded, scroll refs for
|
||||
// jump-to-node, and the brief border flash that lands the eye on a
|
||||
// jumped-to node. Canvas-view has its own analogue (selected-node
|
||||
// + side-sheet open).
|
||||
// List-only UI state: which cards are expanded + scroll refs for
|
||||
// jump-to-node. The flash itself is read from context (flashKey)
|
||||
// so canvas + list share the same source of truth.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(
|
||||
() => new Set(state.nodes.map((n) => n.node_key)),
|
||||
);
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [flashedKey, setFlashedKey] = useState<string | null>(null);
|
||||
|
||||
// Wrap addNode so the new node opens expanded in the list view
|
||||
// (matches the previous behaviour where adding always revealed the
|
||||
@ -137,28 +121,6 @@ export function FlowBuilder() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Jump-to-node: invoked when a user clicks an issue in the validation
|
||||
// panel. Expand the offending card (so the broken field is visible),
|
||||
// scroll it into the viewport, then flash its border so the eye lands
|
||||
// on it. requestAnimationFrame defers the scroll until after React
|
||||
// commits the expanded layout.
|
||||
const jumpToNode = useCallback((key: string) => {
|
||||
setExpanded((prev) => {
|
||||
if (prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
setFlashedKey(key);
|
||||
requestAnimationFrame(() => {
|
||||
const el = nodeRefs.current.get(key);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
setFlashedKey((cur) => (cur === key ? null : cur));
|
||||
}, 1600);
|
||||
}, []);
|
||||
|
||||
const setNodeRef = useCallback(
|
||||
(key: string) => (el: HTMLDivElement | null) => {
|
||||
if (el) nodeRefs.current.set(key, el);
|
||||
@ -167,23 +129,28 @@ export function FlowBuilder() {
|
||||
[],
|
||||
);
|
||||
|
||||
// ---- Render ----
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
|
||||
<Header
|
||||
state={state}
|
||||
setState={setState}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
activating={activating}
|
||||
onSave={handleSave}
|
||||
onStatus={handleStatus}
|
||||
onDelete={handleDelete}
|
||||
canActivate={canActivate}
|
||||
onBack={() => router.push("/flows")}
|
||||
onViewRuns={() => router.push(`/flows/${flow.id}/runs`)}
|
||||
/>
|
||||
// React to validator jumps via the shared flashKey. We DERIVE the
|
||||
// expanded-with-flash set (avoids the "setState inside effect"
|
||||
// smell of mutating `expanded` from a useEffect on flashKey), then
|
||||
// run a side-effect-only effect to scroll the row into view. The
|
||||
// flash class is rendered by NodeCard when its key matches
|
||||
// flashKey; the flash auto-clears in the context so no timer here.
|
||||
const expandedWithFlash = useMemo(() => {
|
||||
if (!flashKey || expanded.has(flashKey)) return expanded;
|
||||
return new Set([...expanded, flashKey]);
|
||||
}, [expanded, flashKey]);
|
||||
useEffect(() => {
|
||||
if (!flashKey) return;
|
||||
// requestAnimationFrame defers the scroll until after React has
|
||||
// committed any expand-induced layout shift.
|
||||
requestAnimationFrame(() => {
|
||||
const el = nodeRefs.current.get(flashKey);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}, [flashKey]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TriggerPanel
|
||||
state={state}
|
||||
setState={setState}
|
||||
@ -212,9 +179,9 @@ export function FlowBuilder() {
|
||||
key={node.node_key}
|
||||
node={node}
|
||||
allNodes={state.nodes}
|
||||
expanded={expanded.has(node.node_key)}
|
||||
expanded={expandedWithFlash.has(node.node_key)}
|
||||
isEntry={state.entry_node_id === node.node_key}
|
||||
isFlashed={flashedKey === node.node_key}
|
||||
isFlashed={flashKey === node.node_key}
|
||||
cardRef={setNodeRef(node.node_key)}
|
||||
issues={issues.filter(
|
||||
(i) => i.scope === "node" && i.node_key === node.node_key,
|
||||
@ -230,169 +197,10 @@ export function FlowBuilder() {
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sticky-bottom so the activate-readiness status follows the
|
||||
user as they scroll through nodes. The parent <main> in the
|
||||
dashboard shell is the scroll container; this stays pinned
|
||||
to the viewport bottom (with a 1rem gap) until the page
|
||||
naturally ends, at which point it falls back into flow. */}
|
||||
<div className="sticky bottom-4 z-10 shadow-xl shadow-slate-950/60">
|
||||
<ValidationPanel issues={issues} onJump={jumpToNode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Header
|
||||
// ============================================================
|
||||
|
||||
function Header({
|
||||
state,
|
||||
setState,
|
||||
dirty,
|
||||
saving,
|
||||
activating,
|
||||
onSave,
|
||||
onStatus,
|
||||
onDelete,
|
||||
canActivate,
|
||||
onBack,
|
||||
onViewRuns,
|
||||
}: {
|
||||
state: BuilderState;
|
||||
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
activating: boolean;
|
||||
onSave: () => void;
|
||||
onStatus: (s: BuilderState["status"]) => void;
|
||||
onDelete: () => void;
|
||||
canActivate: boolean;
|
||||
onBack: () => void;
|
||||
onViewRuns: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1 hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Flows
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Workflow className="h-5 w-5 shrink-0 text-primary" />
|
||||
<Input
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, name: e.target.value }))
|
||||
}
|
||||
placeholder="Flow name"
|
||||
className="max-w-md bg-slate-900 text-lg font-semibold"
|
||||
/>
|
||||
<StatusBadge status={state.status} />
|
||||
{dirty && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
title="Unsaved changes — hit Save to persist"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewRuns()}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{state.status === "active" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onStatus("draft")}
|
||||
disabled={activating}
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PauseCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onStatus("active")}
|
||||
disabled={activating || !canActivate}
|
||||
title={
|
||||
!canActivate
|
||||
? "Fix the issues below before activating"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} disabled={saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={state.description}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional description (internal — customers don't see this)"
|
||||
className="bg-slate-900 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BuilderState["status"] }) {
|
||||
const cls = {
|
||||
draft: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
|
||||
}[status];
|
||||
return (
|
||||
<Badge variant="outline" className={cn("shrink-0", cls)}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Trigger panel
|
||||
@ -745,105 +553,3 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validation panel — bottom of the editor
|
||||
// ============================================================
|
||||
|
||||
function ValidationPanel({
|
||||
issues,
|
||||
onJump,
|
||||
}: {
|
||||
issues: ValidationIssue[];
|
||||
onJump: (key: string) => void;
|
||||
}) {
|
||||
if (issues.length === 0) {
|
||||
// Slate-950 base + emerald accents so the panel stays readable when
|
||||
// sticky-positioned over scrolled-behind node cards (a translucent
|
||||
// bg-emerald-500/10 would bleed through ugly).
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-slate-950 p-3 text-sm font-medium text-emerald-300">
|
||||
<CircleCheck className="h-4 w-4 shrink-0" />
|
||||
No issues. Ready to activate.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const errors = issues.filter((i) => i.severity === "error");
|
||||
const warnings = issues.filter((i) => i.severity === "warning");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-slate-950 p-3",
|
||||
errors.length > 0 ? "border-red-500/40" : "border-amber-500/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
{errors.length > 0 ? (
|
||||
<CircleAlert className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
<CircleAlert className="h-4 w-4 text-amber-400" />
|
||||
)}
|
||||
{errors.length} error{errors.length === 1 ? "" : "s"},{" "}
|
||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{issues.map((i, ix) => (
|
||||
<IssueLine key={ix} issue={i} onJump={onJump} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueLine({
|
||||
issue,
|
||||
onJump,
|
||||
}: {
|
||||
issue: ValidationIssue;
|
||||
onJump?: (key: string) => void;
|
||||
}) {
|
||||
const tone =
|
||||
issue.severity === "error" ? "text-red-300" : "text-amber-300";
|
||||
const iconTone =
|
||||
issue.severity === "error" ? "text-red-400" : "text-amber-400";
|
||||
const body = (
|
||||
<>
|
||||
<CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{issue.node_key && (
|
||||
<code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
|
||||
{issue.node_key}
|
||||
</code>
|
||||
)}
|
||||
{issue.message}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Only node-scoped issues can jump; trigger-scoped issues have no
|
||||
// destination (the trigger panel is already at the top of the page).
|
||||
if (issue.node_key && onJump) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(issue.node_key!)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-slate-800/60",
|
||||
tone,
|
||||
)}
|
||||
aria-label={`Jump to node ${issue.node_key}`}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md px-2 py-1 text-xs",
|
||||
tone,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
* list view reads.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
@ -91,6 +91,9 @@ import { NodeConfigForm } from "./forms/node-config-form";
|
||||
interface NodeData extends Record<string, unknown> {
|
||||
node: BuilderNode;
|
||||
isEntry: boolean;
|
||||
/** Validator's "look here" pulse — flashes the card border for
|
||||
* ~1.6s. Drives a CSS animation, doesn't change layout. */
|
||||
isFlashed: boolean;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 240;
|
||||
@ -105,7 +108,7 @@ const NODE_HEIGHT = 90;
|
||||
// ============================================================
|
||||
|
||||
function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
const { node, isEntry } = data as NodeData;
|
||||
const { node, isEntry, isFlashed } = data as NodeData;
|
||||
const meta = NODE_META[node.node_type];
|
||||
const summary = summarizeNode(node);
|
||||
const Icon = meta.icon;
|
||||
@ -127,6 +130,10 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary/40"
|
||||
: "border-slate-700 hover:border-slate-600",
|
||||
// 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",
|
||||
)}
|
||||
>
|
||||
{hasTarget && (
|
||||
@ -200,14 +207,30 @@ const NODE_TYPES = { flow: FlowNodeCard };
|
||||
// Root canvas
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Outer wrapper provides the React-Flow context to the inner body,
|
||||
* so `useReactFlow()` works from anywhere in `FlowCanvasInner`
|
||||
* (notably, the pan-to-flash effect). The split is required because
|
||||
* useReactFlow() must be called inside a ReactFlowProvider.
|
||||
*/
|
||||
export function FlowCanvas() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowCanvasInner() {
|
||||
const {
|
||||
state,
|
||||
setState,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
removeNode,
|
||||
flashKey,
|
||||
} = useFlowEditor();
|
||||
const reactFlow = useReactFlow();
|
||||
const builderNodes = state.nodes;
|
||||
const entryNodeId = state.entry_node_id;
|
||||
|
||||
@ -255,6 +278,7 @@ export function FlowCanvas() {
|
||||
data: {
|
||||
node: n,
|
||||
isEntry: n.node_key === entryNodeId,
|
||||
isFlashed: n.node_key === flashKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -276,7 +300,7 @@ export function FlowCanvas() {
|
||||
}));
|
||||
|
||||
return { rfNodes, rfEdges };
|
||||
}, [builderNodes, entryNodeId]);
|
||||
}, [builderNodes, entryNodeId, flashKey]);
|
||||
|
||||
// Drag-to-position: React-Flow tracks the visual drag internally and
|
||||
// fires this once on release. We write the final coordinate back to
|
||||
@ -292,6 +316,21 @@ export function FlowCanvas() {
|
||||
[updateNodePosition],
|
||||
);
|
||||
|
||||
// Pan to the flashed node when the validator panel requests one.
|
||||
// Animate over 400ms; landing zoom is whatever the user already has
|
||||
// (don't force a zoom reset — that would be jarring mid-edit).
|
||||
useEffect(() => {
|
||||
if (!flashKey) return;
|
||||
const node = builderNodes.find((n) => n.node_key === flashKey);
|
||||
if (!node) return;
|
||||
const x = (node.position_x ?? 0) + NODE_WIDTH / 2;
|
||||
const y = (node.position_y ?? 0) + NODE_HEIGHT / 2;
|
||||
reactFlow.setCenter(x, y, {
|
||||
zoom: reactFlow.getZoom(),
|
||||
duration: 400,
|
||||
});
|
||||
}, [flashKey, builderNodes, reactFlow]);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
|
||||
setSelectedNodeKey(node.id);
|
||||
@ -383,14 +422,15 @@ export function FlowCanvas() {
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
|
||||
No nodes yet. Switch to List view to add your first node.
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
|
||||
<p>No nodes yet.</p>
|
||||
<CanvasAddNodeButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<>
|
||||
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
@ -443,7 +483,7 @@ export function FlowCanvas() {
|
||||
onDelete={handleDeleteSelected}
|
||||
onSetEntry={handleSetEntry}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,15 +16,25 @@
|
||||
* feedback was that the list shape made flows "hard to understand".
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutGrid, ListTree } from "lucide-react";
|
||||
|
||||
import { FlowBuilder } from "./flow-builder";
|
||||
import { FlowCanvas } from "./flow-canvas";
|
||||
import { FlowEditorProvider } from "./flow-editor-state";
|
||||
import { EditorHeader } from "./header";
|
||||
import { ValidationPanel } from "./validation-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||
|
||||
/**
|
||||
* Below this viewport width we force list view and hide the toggle.
|
||||
* Canvas with drag-to-connect on a phone is unusable — handles are
|
||||
* ~10px and live finger drags from one node to another aren't a
|
||||
* practical workflow. Matches Tailwind's `md` breakpoint.
|
||||
*/
|
||||
const MOBILE_BREAKPOINT = "(max-width: 767px)";
|
||||
|
||||
type View = "canvas" | "list";
|
||||
|
||||
const STORAGE_KEY = "wacrm.flowEditor.view";
|
||||
@ -50,6 +60,13 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
return "canvas";
|
||||
});
|
||||
|
||||
// Live mobile detection. We don't render canvas under the
|
||||
// breakpoint regardless of `view` — but we keep `view` itself
|
||||
// intact so the user's preference comes back when they widen
|
||||
// again (e.g. rotating a tablet, resizing a window).
|
||||
const isMobile = useMatchMedia(MOBILE_BREAKPOINT);
|
||||
const effectiveView: View = isMobile ? "list" : view;
|
||||
|
||||
const choose = (next: View) => {
|
||||
setView(next);
|
||||
try {
|
||||
@ -61,34 +78,66 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
|
||||
return (
|
||||
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Editor view"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
|
||||
>
|
||||
<ToggleButton
|
||||
active={view === "canvas"}
|
||||
onClick={() => choose("canvas")}
|
||||
icon={<LayoutGrid className="h-3 w-3" />}
|
||||
label="Canvas"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={view === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<ListTree className="h-3 w-3" />}
|
||||
label="List"
|
||||
/>
|
||||
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
|
||||
<EditorHeader />
|
||||
{!isMobile && (
|
||||
<div className="flex items-center justify-end">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Editor view"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
|
||||
>
|
||||
<ToggleButton
|
||||
active={effectiveView === "canvas"}
|
||||
onClick={() => choose("canvas")}
|
||||
icon={<LayoutGrid className="h-3 w-3" />}
|
||||
label="Canvas"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={effectiveView === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<ListTree className="h-3 w-3" />}
|
||||
label="List"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||
{effectiveView === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||
|
||||
{/* Sticky-bottom validation panel mirrors the placement used
|
||||
when this lived inside FlowBuilder — the activate-readiness
|
||||
status follows the user as they scroll, in either view. */}
|
||||
<div className="sticky bottom-4 z-10 shadow-xl shadow-slate-950/60">
|
||||
<ValidationPanel />
|
||||
</div>
|
||||
</div>
|
||||
</FlowEditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny `useMatchMedia` shim. We could pull in `react-responsive` but
|
||||
* this is the only consumer and matchMedia is one of those browser
|
||||
* APIs that doesn't need a dependency.
|
||||
*/
|
||||
function useMatchMedia(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
// Safari < 14 still uses addListener; addEventListener is the
|
||||
// modern path. Both fire identically.
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@ -102,6 +103,20 @@ export interface FlowEditorContextValue {
|
||||
save: () => Promise<void>;
|
||||
setStatus: (status: BuilderState["status"]) => Promise<void>;
|
||||
deleteFlow: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Transient "look here" signal. Set when the validation panel's
|
||||
* issue is clicked — both views subscribe: list scrolls the row
|
||||
* into view and flashes its border, canvas pans the viewport to
|
||||
* the node and flashes its card. Auto-clears after 1600ms so the
|
||||
* flash is a one-shot.
|
||||
*
|
||||
* Lives in context (not local view state) so the panel can be
|
||||
* rendered ONCE in the shell and trigger flashes in whichever
|
||||
* view is currently mounted, without per-view plumbing.
|
||||
*/
|
||||
flashKey: string | null;
|
||||
requestFlash: (key: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -231,6 +246,31 @@ export function FlowEditorProvider({
|
||||
setStateRaw(updaterOrValue);
|
||||
}, []);
|
||||
|
||||
// Cross-view "look here" signal (see FlowEditorContextValue docs).
|
||||
// Tracked via a ref alongside state so a rapid second click on a
|
||||
// different issue cancels the previous timeout instead of letting
|
||||
// the first flash linger past the new one.
|
||||
const [flashKey, setFlashKey] = useState<string | null>(null);
|
||||
const flashTimeoutRef = useRef<number | null>(null);
|
||||
const requestFlash = useCallback((key: string) => {
|
||||
if (flashTimeoutRef.current !== null) {
|
||||
window.clearTimeout(flashTimeoutRef.current);
|
||||
}
|
||||
setFlashKey(key);
|
||||
flashTimeoutRef.current = window.setTimeout(() => {
|
||||
setFlashKey(null);
|
||||
flashTimeoutRef.current = null;
|
||||
}, 1600);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimeoutRef.current !== null) {
|
||||
window.clearTimeout(flashTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Browser-level reload / tab-close / external-link guard. SPA
|
||||
// navigation (sidebar links, back button) isn't covered — Next 16
|
||||
// routes through the App Router and beforeunload doesn't fire on
|
||||
@ -462,6 +502,8 @@ export function FlowEditorProvider({
|
||||
save,
|
||||
setStatus,
|
||||
deleteFlow,
|
||||
flashKey,
|
||||
requestFlash,
|
||||
}),
|
||||
[
|
||||
initialFlow,
|
||||
@ -480,6 +522,8 @@ export function FlowEditorProvider({
|
||||
save,
|
||||
setStatus,
|
||||
deleteFlow,
|
||||
flashKey,
|
||||
requestFlash,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
174
src/components/flows/header.tsx
Normal file
174
src/components/flows/header.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editor header — flow name / description, status badge, dirty
|
||||
* indicator, and the action buttons (Save, Activate/Pause, Delete,
|
||||
* View runs, Back).
|
||||
*
|
||||
* Lifted out of flow-builder.tsx so the same header renders above
|
||||
* both views in FlowEditorShell. Without this, canvas users had no
|
||||
* way to save without toggling to list view.
|
||||
*
|
||||
* Reads everything from the editor context (`useFlowEditor`) so it
|
||||
* stays in sync with whichever view is mutating state, and routes
|
||||
* router navigation locally (back to /flows, View runs to
|
||||
* /flows/[id]/runs) — those don't belong in the hook.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Save,
|
||||
Trash2,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFlowEditor,
|
||||
type BuilderState,
|
||||
} from "./flow-editor-state";
|
||||
|
||||
export function EditorHeader() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
flow,
|
||||
state,
|
||||
setState,
|
||||
dirty,
|
||||
saving,
|
||||
activating,
|
||||
canActivate,
|
||||
save,
|
||||
setStatus,
|
||||
deleteFlow,
|
||||
} = useFlowEditor();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="inline-flex items-center gap-1 hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Flows
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Workflow className="h-5 w-5 shrink-0 text-primary" />
|
||||
<Input
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, name: e.target.value }))
|
||||
}
|
||||
placeholder="Flow name"
|
||||
className="max-w-md bg-slate-900 text-lg font-semibold"
|
||||
/>
|
||||
<StatusBadge status={state.status} />
|
||||
{dirty && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
title="Unsaved changes — hit Save to persist"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/flows/${flow.id}/runs`)}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void deleteFlow()}
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{state.status === "active" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void setStatus("draft")}
|
||||
disabled={activating}
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PauseCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void setStatus("active")}
|
||||
disabled={activating || !canActivate}
|
||||
title={
|
||||
!canActivate
|
||||
? "Fix the issues below before activating"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void save()} disabled={saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={state.description}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional description (internal — customers don't see this)"
|
||||
className="bg-slate-900 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BuilderState["status"] }) {
|
||||
const cls = {
|
||||
draft: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
|
||||
}[status];
|
||||
return (
|
||||
<Badge variant="outline" className={cn("shrink-0", cls)}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
125
src/components/flows/validation-panel.tsx
Normal file
125
src/components/flows/validation-panel.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Validation panel — surfaces every error and warning from
|
||||
* `validateFlowForActivation`. Lives once at the bottom of the
|
||||
* editor shell so it's visible in both views (canvas + list).
|
||||
*
|
||||
* Node-scoped issues are clickable: tapping one calls
|
||||
* `requestFlash(node_key)` on the editor context. List view's
|
||||
* useEffect on `flashKey` expands + scrolls + flashes the row;
|
||||
* canvas view's useEffect pans the viewport + flashes the card.
|
||||
* Both views read the same flashKey so the panel doesn't need
|
||||
* per-view plumbing.
|
||||
*
|
||||
* Trigger-scoped issues are NOT clickable from canvas — trigger
|
||||
* config is a list-only panel (it's a flat form, not a graph
|
||||
* concept). User can switch to List to address them.
|
||||
*/
|
||||
|
||||
import { CircleAlert, CircleCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ValidationIssue } from "@/lib/flows/validate";
|
||||
import { useFlowEditor } from "./flow-editor-state";
|
||||
|
||||
export function ValidationPanel() {
|
||||
const { issues, requestFlash } = useFlowEditor();
|
||||
|
||||
if (issues.length === 0) {
|
||||
// Slate-950 base + emerald accents so the panel stays readable when
|
||||
// sticky-positioned over scrolled-behind node cards (a translucent
|
||||
// bg-emerald-500/10 would bleed through ugly).
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-slate-950 p-3 text-sm font-medium text-emerald-300">
|
||||
<CircleCheck className="h-4 w-4 shrink-0" />
|
||||
No issues. Ready to activate.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const errors = issues.filter((i) => i.severity === "error");
|
||||
const warnings = issues.filter((i) => i.severity === "warning");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-slate-950 p-3",
|
||||
errors.length > 0 ? "border-red-500/40" : "border-amber-500/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
{errors.length > 0 ? (
|
||||
<CircleAlert className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
<CircleAlert className="h-4 w-4 text-amber-400" />
|
||||
)}
|
||||
{errors.length} error{errors.length === 1 ? "" : "s"},{" "}
|
||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{issues.map((i, ix) => (
|
||||
<IssueLine key={ix} issue={i} onJump={requestFlash} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported so the per-node card (list view) and the trigger panel
|
||||
* can render the same "icon + node key chip + message" formatting
|
||||
* for their own per-row issue lists without re-implementing the
|
||||
* tone / icon / accessibility logic.
|
||||
*/
|
||||
export function IssueLine({
|
||||
issue,
|
||||
onJump,
|
||||
}: {
|
||||
issue: ValidationIssue;
|
||||
onJump?: (key: string) => void;
|
||||
}) {
|
||||
const tone =
|
||||
issue.severity === "error" ? "text-red-300" : "text-amber-300";
|
||||
const iconTone =
|
||||
issue.severity === "error" ? "text-red-400" : "text-amber-400";
|
||||
const body = (
|
||||
<>
|
||||
<CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{issue.node_key && (
|
||||
<code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
|
||||
{issue.node_key}
|
||||
</code>
|
||||
)}
|
||||
{issue.message}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Only node-scoped issues can jump; trigger-scoped issues have no
|
||||
// destination (the trigger panel is list-only and already at the
|
||||
// top of that view).
|
||||
if (issue.node_key && onJump) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(issue.node_key!)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-slate-800/60",
|
||||
tone,
|
||||
)}
|
||||
aria-label={`Jump to node ${issue.node_key}`}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md px-2 py-1 text-xs",
|
||||
tone,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user