wacrm/src/components/flows/header.tsx
Arnas Donauskas c5a4439f99
feat(flows): canvas / mind-map view (PR 3 of 3 — polish)
Closes the trilogy. Three high-value gaps from PRs 2a/2b addressed:

  1. Canvas users couldn't save. The Header (with Save / Activate /
     Pause / Delete buttons) lived inside FlowBuilder so canvas-only
     users had to toggle to List to persist any edit. Lifted into a
     new components/flows/header.tsx that consumes useFlowEditor()
     and is mounted ONCE in FlowEditorShell, above both views.

  2. Validator issues weren't clickable from canvas. The
     ValidationPanel was also FlowBuilder-internal. Lifted into
     components/flows/validation-panel.tsx + mounted in the shell,
     pinned sticky-bottom in either view. Clicks now go through the
     editor context's new requestFlash(key) — both views subscribe:
       - List view derives an "expanded with flash" set + an effect
         scrolls the row into view. The flash class is rendered when
         the card's key matches context.flashKey.
       - Canvas view's pan-on-flash effect calls
         reactFlow.setCenter() to bring the node into the viewport,
         and the card renders an amber ring while flashed.
     The flash auto-clears in the context after 1600ms; rapid
     clicks on different issues cancel the previous timeout.

  3. Canvas was unusable on phones — handles are ~10px and live
     finger-drags between them aren't a practical workflow. Added
     a useMatchMedia('(max-width: 767px)') guard in the shell that
     forces list view and hides the toggle below the breakpoint.
     The user's preferred view is preserved (just shadowed) so it
     comes back when they widen.

Plus minor cleanup: the canvas's empty-state used to direct users
to switch to List view to add a node, but the floating "+" button
shipped in PR 2b made that stale — now shows the same Add-node
dropdown inline.

flow-builder.tsx shrinks by another ~370 lines (Header + StatusBadge
+ ValidationPanel + IssueLine + the local flashedKey + jumpToNode
all moved or replaced by context subscription).

No new tests — the change is structural + UI-only. Existing 319
still pass; typecheck, lint, build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:04:20 +02:00

175 lines
5.2 KiB
TypeScript

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