mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(flows): canvas / mind-map view (PR 3 of 3 — polish)
Closes the trilogy. Three high-value gaps from PRs 2a/2b addressed:
1. Canvas users couldn't save. The Header (with Save / Activate /
Pause / Delete buttons) lived inside FlowBuilder so canvas-only
users had to toggle to List to persist any edit. Lifted into a
new components/flows/header.tsx that consumes useFlowEditor()
and is mounted ONCE in FlowEditorShell, above both views.
2. Validator issues weren't clickable from canvas. The
ValidationPanel was also FlowBuilder-internal. Lifted into
components/flows/validation-panel.tsx + mounted in the shell,
pinned sticky-bottom in either view. Clicks now go through the
editor context's new requestFlash(key) — both views subscribe:
- List view derives an "expanded with flash" set + an effect
scrolls the row into view. The flash class is rendered when
the card's key matches context.flashKey.
- Canvas view's pan-on-flash effect calls
reactFlow.setCenter() to bring the node into the viewport,
and the card renders an amber ring while flashed.
The flash auto-clears in the context after 1600ms; rapid
clicks on different issues cancel the previous timeout.
3. Canvas was unusable on phones — handles are ~10px and live
finger-drags between them aren't a practical workflow. Added
a useMatchMedia('(max-width: 767px)') guard in the shell that
forces list view and hides the toggle below the breakpoint.
The user's preferred view is preserved (just shadowed) so it
comes back when they widen.
Plus minor cleanup: the canvas's empty-state used to direct users
to switch to List view to add a node, but the floating "+" button
shipped in PR 2b made that stale — now shows the same Add-node
dropdown inline.
flow-builder.tsx shrinks by another ~370 lines (Header + StatusBadge
+ ValidationPanel + IssueLine + the local flashedKey + jumpToNode
all moved or replaced by context subscription).
No new tests — the change is structural + UI-only. Existing 319
still pass; typecheck, lint, build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62d5d98f06
commit
c5a4439f99
@ -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,7 +78,9 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
|
||||
return (
|
||||
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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"
|
||||
@ -69,26 +88,56 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
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"}
|
||||
active={effectiveView === "canvas"}
|
||||
onClick={() => choose("canvas")}
|
||||
icon={<LayoutGrid className="h-3 w-3" />}
|
||||
label="Canvas"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={view === "list"}
|
||||
active={effectiveView === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<ListTree className="h-3 w-3" />}
|
||||
label="List"
|
||||
/>
|
||||
</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