mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
121 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|