mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #161 from ArnasDon/feat/flow-canvas-polish
feat(flows): canvas / mind-map view, PR 3 of 3 — polish
This commit is contained in:
commit
1b66431acf
@ -3,39 +3,32 @@
|
|||||||
/**
|
/**
|
||||||
* Linear-list flow editor.
|
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,7 +78,9 @@ 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">
|
||||||
|
<EditorHeader />
|
||||||
|
{!isMobile && (
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div
|
<div
|
||||||
role="group"
|
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"
|
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
active={view === "canvas"}
|
active={effectiveView === "canvas"}
|
||||||
onClick={() => choose("canvas")}
|
onClick={() => choose("canvas")}
|
||||||
icon={<LayoutGrid className="h-3 w-3" />}
|
icon={<LayoutGrid className="h-3 w-3" />}
|
||||||
label="Canvas"
|
label="Canvas"
|
||||||
/>
|
/>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
active={view === "list"}
|
active={effectiveView === "list"}
|
||||||
onClick={() => choose("list")}
|
onClick={() => choose("list")}
|
||||||
icon={<ListTree className="h-3 w-3" />}
|
icon={<ListTree className="h-3 w-3" />}
|
||||||
label="List"
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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