wacrm/src/components/flows/flow-editor-shell.tsx
Arnas Donauskas bc859bcbcd
feat(flows): canvas / mind-map view (PR 1 of 3 — read-only)
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).

PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).

Pieces:
  - New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
    pre-existing flows whose position_x / position_y are all zero —
    flow_nodes already reserved those columns in migration 010
    precisely for this).
  - Shared module src/components/flows/shared.tsx lifts NodeType,
    BuilderNode, NODE_META, summarizeNode, truncate so both views
    render nodes identically.
  - Pure helpers (no React, fully unit-tested):
      src/lib/flows/edges.ts   — derive labeled canvas edges per
                                 outgoing slot (button, list row,
                                 condition branch)
      src/lib/flows/layout.ts  — dagre wrapper + shouldAutoLayout
                                 guard so manually-positioned flows
                                 never get rearranged on open
  - FlowCanvas — client component, custom node renderer matching the
    list view's collapsed-card styling, MiniMap + Controls, attribution
    hidden.
  - FlowEditorShell — owns the Canvas / List toggle, conditionally
    mounts the right view.

21 new unit tests (layout + edges); existing 271 still pass.

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

121 lines
3.4 KiB
TypeScript

"use client";
/**
* View-switcher for the flow editor.
*
* Renders a small Canvas / List pill above whichever view is active,
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
* separate component:
* - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else.
*
* View choice persists per-browser via localStorage so a power user
* who prefers the list isn't fighting the default on every load.
* Canvas is the default for everyone else — the original user
* feedback was that the list shape made flows "hard to understand".
*/
import { useState } from "react";
import { LayoutGrid, ListTree } from "lucide-react";
import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view";
interface Props {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
}
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
// Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only
// mounts us AFTER a client-side fetch resolves — there's no SSR
// pass for this subtree, so no hydration mismatch to worry about.
// Default to `canvas` (the new default) when nothing is saved.
const [view, setView] = useState<View>(() => {
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === "canvas" || saved === "list") return saved;
} catch {
// Private browsing / disabled storage — fall through to default.
}
return "canvas";
});
const choose = (next: View) => {
setView(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-end">
<div
role="group"
aria-label="Editor view"
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
>
<ToggleButton
active={view === "canvas"}
onClick={() => choose("canvas")}
icon={<LayoutGrid className="h-3 w-3" />}
label="Canvas"
/>
<ToggleButton
active={view === "list"}
onClick={() => choose("list")}
icon={<ListTree className="h-3 w-3" />}
label="List"
/>
</div>
</div>
{view === "canvas" ? (
<FlowCanvas initialFlow={initialFlow} initialNodes={initialNodes} />
) : (
<FlowBuilder initialFlow={initialFlow} initialNodes={initialNodes} />
)}
</div>
);
}
function ToggleButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
active
? "bg-slate-700 text-slate-100"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200",
)}
>
{icon}
{label}
</button>
);
}