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:
Arnas Donauskas 2026-05-29 21:09:03 +02:00 committed by GitHub
commit 1b66431acf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 502 additions and 364 deletions

View File

@ -3,39 +3,32 @@
/** /**
* Linear-list flow editor. * Linear-list flow editor.
* *
* The whole flow (header, trigger config, node list, validation panel) * Renders the trigger panel, entry-node picker, and the per-node card
* is owned by this single component. State lives client-side as a * list. Header and validation panel are NOT owned here — they live
* single `BuilderState` object; `Save` PUTs the whole structure to * once in FlowEditorShell so they show in both views (lifted in PR 3
* `/api/flows/[id]`; `Activate` hits `/api/flows/[id]/activate`. * so canvas users can also save + see validator issues).
* *
* Why one big file: keeps the diff between fields + the form code * State lives in the shared `useFlowEditor()` context — toggling
* obvious, matches the existing `automation-builder.tsx` shape, and * Canvas ⇄ List never loses edits, and a drag on the canvas updates
* sidesteps over-componentization for a UI that will be replaced by a * the same nodes the list view reads.
* react-flow canvas in v2 anyway. The node-config sub-forms live in *
* the same file as small components rather than separate modules. * 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { import {
ArrowLeft,
CircleCheck,
CircleAlert, CircleAlert,
History,
Loader2,
Plus, Plus,
Save,
Trash2, Trash2,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
CornerDownRight, CornerDownRight,
PauseCircle,
PlayCircle,
Workflow,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -60,6 +53,7 @@ import {
} from "./shared"; } from "./shared";
import { NodeConfigForm } from "./forms/node-config-form"; import { NodeConfigForm } from "./forms/node-config-form";
import { NodeKeySelect } from "./forms/fields"; import { NodeKeySelect } from "./forms/fields";
import { IssueLine } from "./validation-panel";
import { import {
useFlowEditor, useFlowEditor,
type BuilderState, type BuilderState,
@ -76,34 +70,24 @@ import {
// ============================================================ // ============================================================
export function FlowBuilder() { export function FlowBuilder() {
const router = useRouter();
const { const {
flow,
state, state,
setState, setState,
dirty,
saving,
activating,
issues, issues,
canActivate, flashKey,
addNode: addNodeCtx, addNode: addNodeCtx,
updateNode, updateNode,
updateNodeConfig, updateNodeConfig,
removeNode: removeNodeCtx, removeNode: removeNodeCtx,
save: handleSave,
setStatus: handleStatus,
deleteFlow: handleDelete,
} = useFlowEditor(); } = useFlowEditor();
// List-only UI state: which cards are expanded, scroll refs for // 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 // jump-to-node. The flash itself is read from context (flashKey)
// jumped-to node. Canvas-view has its own analogue (selected-node // so canvas + list share the same source of truth.
// + side-sheet open).
const [expanded, setExpanded] = useState<Set<string>>( const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(state.nodes.map((n) => n.node_key)), () => new Set(state.nodes.map((n) => n.node_key)),
); );
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [flashedKey, setFlashedKey] = useState<string | null>(null);
// Wrap addNode so the new node opens expanded in the list view // Wrap addNode so the new node opens expanded in the list view
// (matches the previous behaviour where adding always revealed the // (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( const setNodeRef = useCallback(
(key: string) => (el: HTMLDivElement | null) => { (key: string) => (el: HTMLDivElement | null) => {
if (el) nodeRefs.current.set(key, el); if (el) nodeRefs.current.set(key, el);
@ -167,23 +129,28 @@ export function FlowBuilder() {
[], [],
); );
// ---- Render ---- // React to validator jumps via the shared flashKey. We DERIVE the
return ( // expanded-with-flash set (avoids the "setState inside effect"
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6"> // smell of mutating `expanded` from a useEffect on flashKey), then
<Header // run a side-effect-only effect to scroll the row into view. The
state={state} // flash class is rendered by NodeCard when its key matches
setState={setState} // flashKey; the flash auto-clears in the context so no timer here.
dirty={dirty} const expandedWithFlash = useMemo(() => {
saving={saving} if (!flashKey || expanded.has(flashKey)) return expanded;
activating={activating} return new Set([...expanded, flashKey]);
onSave={handleSave} }, [expanded, flashKey]);
onStatus={handleStatus} useEffect(() => {
onDelete={handleDelete} if (!flashKey) return;
canActivate={canActivate} // requestAnimationFrame defers the scroll until after React has
onBack={() => router.push("/flows")} // committed any expand-induced layout shift.
onViewRuns={() => router.push(`/flows/${flow.id}/runs`)} requestAnimationFrame(() => {
/> const el = nodeRefs.current.get(flashKey);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}, [flashKey]);
return (
<div className="flex flex-col gap-6">
<TriggerPanel <TriggerPanel
state={state} state={state}
setState={setState} setState={setState}
@ -212,9 +179,9 @@ export function FlowBuilder() {
key={node.node_key} key={node.node_key}
node={node} node={node}
allNodes={state.nodes} allNodes={state.nodes}
expanded={expanded.has(node.node_key)} expanded={expandedWithFlash.has(node.node_key)}
isEntry={state.entry_node_id === 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)} cardRef={setNodeRef(node.node_key)}
issues={issues.filter( issues={issues.filter(
(i) => i.scope === "node" && i.node_key === node.node_key, (i) => i.scope === "node" && i.node_key === node.node_key,
@ -230,169 +197,10 @@ export function FlowBuilder() {
)) ))
)} )}
</section> </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> </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 // 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>
);
}

View File

@ -36,7 +36,7 @@
* list view reads. * list view reads.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Background, Background,
Controls, Controls,
@ -91,6 +91,9 @@ import { NodeConfigForm } from "./forms/node-config-form";
interface NodeData extends Record<string, unknown> { interface NodeData extends Record<string, unknown> {
node: BuilderNode; node: BuilderNode;
isEntry: boolean; 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; const NODE_WIDTH = 240;
@ -105,7 +108,7 @@ const NODE_HEIGHT = 90;
// ============================================================ // ============================================================
function FlowNodeCard({ data, selected }: NodeProps) { 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 meta = NODE_META[node.node_type];
const summary = summarizeNode(node); const summary = summarizeNode(node);
const Icon = meta.icon; const Icon = meta.icon;
@ -127,6 +130,10 @@ function FlowNodeCard({ data, selected }: NodeProps) {
selected selected
? "border-primary ring-1 ring-primary/40" ? "border-primary ring-1 ring-primary/40"
: "border-slate-700 hover:border-slate-600", : "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 && ( {hasTarget && (
@ -200,14 +207,30 @@ const NODE_TYPES = { flow: FlowNodeCard };
// Root canvas // 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() { export function FlowCanvas() {
return (
<ReactFlowProvider>
<FlowCanvasInner />
</ReactFlowProvider>
);
}
function FlowCanvasInner() {
const { const {
state, state,
setState, setState,
updateNodeConfig, updateNodeConfig,
updateNodePosition, updateNodePosition,
removeNode, removeNode,
flashKey,
} = useFlowEditor(); } = useFlowEditor();
const reactFlow = useReactFlow();
const builderNodes = state.nodes; const builderNodes = state.nodes;
const entryNodeId = state.entry_node_id; const entryNodeId = state.entry_node_id;
@ -255,6 +278,7 @@ export function FlowCanvas() {
data: { data: {
node: n, node: n,
isEntry: n.node_key === entryNodeId, isEntry: n.node_key === entryNodeId,
isFlashed: n.node_key === flashKey,
}, },
}; };
}); });
@ -276,7 +300,7 @@ export function FlowCanvas() {
})); }));
return { rfNodes, rfEdges }; return { rfNodes, rfEdges };
}, [builderNodes, entryNodeId]); }, [builderNodes, entryNodeId, flashKey]);
// Drag-to-position: React-Flow tracks the visual drag internally and // Drag-to-position: React-Flow tracks the visual drag internally and
// fires this once on release. We write the final coordinate back to // fires this once on release. We write the final coordinate back to
@ -292,6 +316,21 @@ export function FlowCanvas() {
[updateNodePosition], [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( const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: RfNode<NodeData>) => { (_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id); setSelectedNodeKey(node.id);
@ -383,14 +422,15 @@ export function FlowCanvas() {
if (rfNodes.length === 0) { if (rfNodes.length === 0) {
return ( 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"> <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">
No nodes yet. Switch to List view to add your first node. <p>No nodes yet.</p>
<CanvasAddNodeButton />
</div> </div>
); );
} }
return ( return (
<ReactFlowProvider> <>
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950"> <div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
<ReactFlow <ReactFlow
nodes={rfNodes} nodes={rfNodes}
@ -443,7 +483,7 @@ export function FlowCanvas() {
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onSetEntry={handleSetEntry} onSetEntry={handleSetEntry}
/> />
</ReactFlowProvider> </>
); );
} }

View File

@ -16,15 +16,25 @@
* feedback was that the list shape made flows "hard to understand". * 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 { LayoutGrid, ListTree } from "lucide-react";
import { FlowBuilder } from "./flow-builder"; import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas"; import { FlowCanvas } from "./flow-canvas";
import { FlowEditorProvider } from "./flow-editor-state"; import { FlowEditorProvider } from "./flow-editor-state";
import { EditorHeader } from "./header";
import { ValidationPanel } from "./validation-panel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; 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"; type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view"; const STORAGE_KEY = "wacrm.flowEditor.view";
@ -50,6 +60,13 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
return "canvas"; 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) => { const choose = (next: View) => {
setView(next); setView(next);
try { try {
@ -61,34 +78,66 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
return ( return (
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}> <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">
<div className="flex items-center justify-end"> <EditorHeader />
<div {!isMobile && (
role="group" <div className="flex items-center justify-end">
aria-label="Editor view" <div
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs" role="group"
> aria-label="Editor view"
<ToggleButton className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
active={view === "canvas"} >
onClick={() => choose("canvas")} <ToggleButton
icon={<LayoutGrid className="h-3 w-3" />} active={effectiveView === "canvas"}
label="Canvas" onClick={() => choose("canvas")}
/> icon={<LayoutGrid className="h-3 w-3" />}
<ToggleButton label="Canvas"
active={view === "list"} />
onClick={() => choose("list")} <ToggleButton
icon={<ListTree className="h-3 w-3" />} active={effectiveView === "list"}
label="List" onClick={() => choose("list")}
/> icon={<ListTree className="h-3 w-3" />}
label="List"
/>
</div>
</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> </div>
</FlowEditorProvider> </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({ function ToggleButton({
active, active,
onClick, onClick,

View File

@ -38,6 +38,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@ -102,6 +103,20 @@ export interface FlowEditorContextValue {
save: () => Promise<void>; save: () => Promise<void>;
setStatus: (status: BuilderState["status"]) => Promise<void>; setStatus: (status: BuilderState["status"]) => Promise<void>;
deleteFlow: () => 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); 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 // Browser-level reload / tab-close / external-link guard. SPA
// navigation (sidebar links, back button) isn't covered — Next 16 // navigation (sidebar links, back button) isn't covered — Next 16
// routes through the App Router and beforeunload doesn't fire on // routes through the App Router and beforeunload doesn't fire on
@ -462,6 +502,8 @@ export function FlowEditorProvider({
save, save,
setStatus, setStatus,
deleteFlow, deleteFlow,
flashKey,
requestFlash,
}), }),
[ [
initialFlow, initialFlow,
@ -480,6 +522,8 @@ export function FlowEditorProvider({
save, save,
setStatus, setStatus,
deleteFlow, deleteFlow,
flashKey,
requestFlash,
], ],
); );

View 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>
);
}

View 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>
);
}