diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 1a0c56c..e67b64c 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -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>( () => new Set(state.nodes.map((n) => n.node_key)), ); const nodeRefs = useRef>(new Map()); - const [flashedKey, setFlashedKey] = useState(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 ( -
-
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 ( +
i.scope === "node" && i.node_key === node.node_key, @@ -230,169 +197,10 @@ export function FlowBuilder() { )) )} - - {/* Sticky-bottom so the activate-readiness status follows the - user as they scroll through nodes. The parent
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. */} -
- -
); } -// ============================================================ -// Header -// ============================================================ - -function Header({ - state, - setState, - dirty, - saving, - activating, - onSave, - onStatus, - onDelete, - canActivate, - onBack, - onViewRuns, -}: { - state: BuilderState; - setState: React.Dispatch>; - dirty: boolean; - saving: boolean; - activating: boolean; - onSave: () => void; - onStatus: (s: BuilderState["status"]) => void; - onDelete: () => void; - canActivate: boolean; - onBack: () => void; - onViewRuns: () => void; -}) { - return ( -
-
- -
-
-
- - - setState((s) => ({ ...s, name: e.target.value })) - } - placeholder="Flow name" - className="max-w-md bg-slate-900 text-lg font-semibold" - /> - - {dirty && ( - - - Edited - - )} -
-
- - - {state.status === "active" ? ( - - ) : ( - - )} - -
-
- - setState((s) => ({ ...s, description: e.target.value })) - } - placeholder="Optional description (internal — customers don't see this)" - className="bg-slate-900 text-sm" - /> -
- ); -} - -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 ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ); -} // ============================================================ // 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 ( -
- - No issues. Ready to activate. -
- ); - } - const errors = issues.filter((i) => i.severity === "error"); - const warnings = issues.filter((i) => i.severity === "warning"); - return ( -
0 ? "border-red-500/40" : "border-amber-500/40", - )} - > -
- {errors.length > 0 ? ( - - ) : ( - - )} - {errors.length} error{errors.length === 1 ? "" : "s"},{" "} - {warnings.length} warning{warnings.length === 1 ? "" : "s"} -
-
- {issues.map((i, ix) => ( - - ))} -
-
- ); -} - -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 = ( - <> - - - {issue.node_key && ( - - {issue.node_key} - - )} - {issue.message} - - - ); - - // 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 ( - - ); - } - return ( -
- {body} -
- ); -} diff --git a/src/components/flows/flow-canvas.tsx b/src/components/flows/flow-canvas.tsx index 02b5022..a1639be 100644 --- a/src/components/flows/flow-canvas.tsx +++ b/src/components/flows/flow-canvas.tsx @@ -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 { 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 ( + + + + ); +} + +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) => { setSelectedNodeKey(node.id); @@ -383,14 +422,15 @@ export function FlowCanvas() { if (rfNodes.length === 0) { return ( -
- No nodes yet. Switch to List view to add your first node. +
+

No nodes yet.

+
); } return ( - + <>
- + ); } diff --git a/src/components/flows/flow-editor-shell.tsx b/src/components/flows/flow-editor-shell.tsx index 6f0005d..b2b1f6a 100644 --- a/src/components/flows/flow-editor-shell.tsx +++ b/src/components/flows/flow-editor-shell.tsx @@ -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 ( -
-
-
- choose("canvas")} - icon={} - label="Canvas" - /> - choose("list")} - icon={} - label="List" - /> +
+ + {!isMobile && ( +
+
+ choose("canvas")} + icon={} + label="Canvas" + /> + choose("list")} + icon={} + label="List" + /> +
-
+ )} - {view === "canvas" ? : } + {effectiveView === "canvas" ? : } + + {/* 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. */} +
+ +
); } +/** + * 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(() => { + 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, diff --git a/src/components/flows/flow-editor-state.tsx b/src/components/flows/flow-editor-state.tsx index aafff27..f377fd6 100644 --- a/src/components/flows/flow-editor-state.tsx +++ b/src/components/flows/flow-editor-state.tsx @@ -38,6 +38,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -102,6 +103,20 @@ export interface FlowEditorContextValue { save: () => Promise; setStatus: (status: BuilderState["status"]) => Promise; deleteFlow: () => Promise; + + /** + * 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(null); + const flashTimeoutRef = useRef(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, ], ); diff --git a/src/components/flows/header.tsx b/src/components/flows/header.tsx new file mode 100644 index 0000000..919236e --- /dev/null +++ b/src/components/flows/header.tsx @@ -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 ( +
+
+ +
+
+
+ + + setState((s) => ({ ...s, name: e.target.value })) + } + placeholder="Flow name" + className="max-w-md bg-slate-900 text-lg font-semibold" + /> + + {dirty && ( + + + Edited + + )} +
+
+ + + {state.status === "active" ? ( + + ) : ( + + )} + +
+
+ + setState((s) => ({ ...s, description: e.target.value })) + } + placeholder="Optional description (internal — customers don't see this)" + className="bg-slate-900 text-sm" + /> +
+ ); +} + +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 ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/src/components/flows/validation-panel.tsx b/src/components/flows/validation-panel.tsx new file mode 100644 index 0000000..5bec91b --- /dev/null +++ b/src/components/flows/validation-panel.tsx @@ -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 ( +
+ + No issues. Ready to activate. +
+ ); + } + const errors = issues.filter((i) => i.severity === "error"); + const warnings = issues.filter((i) => i.severity === "warning"); + return ( +
0 ? "border-red-500/40" : "border-amber-500/40", + )} + > +
+ {errors.length > 0 ? ( + + ) : ( + + )} + {errors.length} error{errors.length === 1 ? "" : "s"},{" "} + {warnings.length} warning{warnings.length === 1 ? "" : "s"} +
+
+ {issues.map((i, ix) => ( + + ))} +
+
+ ); +} + +/** + * 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 = ( + <> + + + {issue.node_key && ( + + {issue.node_key} + + )} + {issue.message} + + + ); + + // 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 ( + + ); + } + return ( +
+ {body} +
+ ); +}