mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
175 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|