mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(flows): canvas / mind-map view (PR 2a of 3 — editable foundation)
Builds on PR 1's read-only canvas. This PR brings the canvas to ~75%
of list-view parity: drag-to-reposition (persisted) and click-to-edit
via a side panel that mounts the same per-node forms the list view
uses. Drag-to-connect + add/delete from canvas land in PR 2b.
Architecture (the bulk of the diff):
- Extracted the per-node forms (SendButtonsForm, SendListForm,
SendMediaForm, ConditionForm, SetTagForm + inline forms for the
smaller types) plus the shared field components (TextRow,
NextNodeRow, NodeKeySelect) from flow-builder.tsx into
src/components/flows/forms/. Both views now mount the SAME
NodeConfigForm dispatcher so behaviour can't drift between
"what the list editor shows" and "what the canvas side panel
shows".
- Lifted all editor state into a context/hook
(src/components/flows/flow-editor-state.tsx) with a
FlowEditorProvider that wraps both views in flow-editor-shell.
Single source of truth: toggling Canvas ⇄ List never resets
unsaved edits, and a drag on the canvas updates the same nodes
array the list view reads.
- flow-builder.tsx shrinks from ~2400 lines to ~900 (extracted
forms) and stops owning state (now consumes via useFlowEditor()).
List-only UI state — expanded cards, scroll refs, jump-to-node
flash — stays internal.
Canvas additions:
- onNodeDragStop writes the new position to context via
updateNodePosition (flips dirty; serialized in the existing PUT
/api/flows/[id] body — the route already accepts position_x /
position_y per migration 010).
- onNodeClick opens a controlled shadcn Sheet (right-side panel)
with the per-node form, plus "Set as entry" and "Delete node"
buttons. Esc / overlay / close button all close.
What's intentionally still NOT in this PR (PR 2b):
- Per-slot Handle components on the custom node renderer.
- Drag-to-connect handler that maps target → updated
next_node_key for the right slot.
- Floating "+" button to add nodes from canvas.
- Keyboard / context-menu delete.
Tests: 9 new pure-helper tests (uniqueNodeKey + defaultConfigFor
self-consistency). Hook integration tests are deferred — would
require @testing-library/react + jsdom infra. Existing 271 tests
still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9a1fb52e7
commit
1645034e1a
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,7 @@
|
||||
* that has to stay stable across views.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
@ -36,24 +36,30 @@ import {
|
||||
type Node as RfNode,
|
||||
type Edge as RfEdge,
|
||||
type NodeProps,
|
||||
type OnNodeDrag,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { deriveCanvasEdges } from "@/lib/flows/edges";
|
||||
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
||||
import {
|
||||
NODE_META,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
type NodeType,
|
||||
} from "./shared";
|
||||
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
|
||||
|
||||
interface FlowCanvasProps {
|
||||
initialFlow: FlowRow;
|
||||
initialNodes: FlowNodeRow[];
|
||||
}
|
||||
import { useFlowEditor } from "./flow-editor-state";
|
||||
import { NodeConfigForm } from "./forms/node-config-form";
|
||||
|
||||
// React-Flow node `data` payload — the bits our custom renderer needs.
|
||||
interface NodeData extends Record<string, unknown> {
|
||||
@ -115,16 +121,30 @@ const NODE_TYPES = { flow: FlowNodeCard };
|
||||
// Root canvas
|
||||
// ============================================================
|
||||
|
||||
export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
||||
const { rfNodes, rfEdges } = useMemo(() => {
|
||||
const builderNodes: BuilderNode[] = initialNodes.map((n) => ({
|
||||
node_key: n.node_key,
|
||||
node_type: n.node_type as NodeType,
|
||||
config: n.config as Record<string, unknown>,
|
||||
position_x: n.position_x,
|
||||
position_y: n.position_y,
|
||||
}));
|
||||
export function FlowCanvas() {
|
||||
const {
|
||||
state,
|
||||
setState,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
removeNode,
|
||||
} = useFlowEditor();
|
||||
const builderNodes = state.nodes;
|
||||
const entryNodeId = state.entry_node_id;
|
||||
|
||||
// Side-panel state — which node's form is open. Canvas-only UI; the
|
||||
// list view's analogue is the per-card expanded set in
|
||||
// flow-builder.tsx.
|
||||
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
selectedNodeKey
|
||||
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
|
||||
: null,
|
||||
[selectedNodeKey, builderNodes],
|
||||
);
|
||||
|
||||
const { rfNodes, rfEdges } = useMemo(() => {
|
||||
const canvasEdges = deriveCanvasEdges(builderNodes);
|
||||
|
||||
// Decide whether to auto-layout. The helper guards against
|
||||
@ -155,12 +175,10 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
||||
},
|
||||
data: {
|
||||
node: n,
|
||||
isEntry: n.node_key === initialFlow.entry_node_id,
|
||||
isEntry: n.node_key === entryNodeId,
|
||||
},
|
||||
// Read-only in PR 1 — block all editing affordances. Drag is
|
||||
// still allowed visually because disabling it makes the canvas
|
||||
// feel inert in a way that's worse than letting users nudge a
|
||||
// tile that won't save.
|
||||
// Drag-to-connect + delete-key still off in PR 2a — those land
|
||||
// in PR 2b with per-slot handles + cascading edge cleanup.
|
||||
connectable: false,
|
||||
deletable: false,
|
||||
};
|
||||
@ -183,7 +201,49 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
||||
}));
|
||||
|
||||
return { rfNodes, rfEdges };
|
||||
}, [initialFlow.entry_node_id, initialNodes]);
|
||||
}, [builderNodes, entryNodeId]);
|
||||
|
||||
// Drag-to-position: React-Flow tracks the visual drag internally and
|
||||
// fires this once on release. We write the final coordinate back to
|
||||
// the editor context (which flips `dirty`); save then ships the new
|
||||
// positions in the existing PUT /api/flows/[id] body (the route
|
||||
// already destructures position_x / position_y per migration 010).
|
||||
// Writing only on dragStop (not on every position-change tick during
|
||||
// the drag) keeps state updates cheap on long drags.
|
||||
const handleNodeDragStop = useCallback<OnNodeDrag<RfNode<NodeData>>>(
|
||||
(_event, node) => {
|
||||
updateNodePosition(node.id, node.position.x, node.position.y);
|
||||
},
|
||||
[updateNodePosition],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
|
||||
setSelectedNodeKey(node.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Wrapped mutators that target the currently-selected node — pass to
|
||||
// the form so each keystroke goes through the editor context (which
|
||||
// flips `dirty` and feeds the validator).
|
||||
const onSelectedUpdateConfig = useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
|
||||
},
|
||||
[selectedNodeKey, updateNodeConfig],
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
if (!selectedNodeKey) return;
|
||||
removeNode(selectedNodeKey);
|
||||
setSelectedNodeKey(null);
|
||||
}, [selectedNodeKey, removeNode]);
|
||||
|
||||
const handleSetEntry = useCallback(() => {
|
||||
if (!selectedNodeKey) return;
|
||||
setState((s) => ({ ...s, entry_node_id: selectedNodeKey }));
|
||||
}, [selectedNodeKey, setState]);
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
return (
|
||||
@ -203,8 +263,10 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
// Read-only knobs — keeps PR 1's surface inert at the
|
||||
// React-Flow level. PR 2 will flip these back on.
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onNodeClick={handleNodeClick}
|
||||
// Drag-to-connect + delete-by-keyboard still off — both land
|
||||
// in PR 2b with per-slot handles + cascading edge cleanup.
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
elementsSelectable={true}
|
||||
@ -228,6 +290,104 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
<NodeEditSheet
|
||||
node={selectedNode}
|
||||
isEntry={selectedNode?.node_key === entryNodeId}
|
||||
allNodes={builderNodes}
|
||||
onClose={() => setSelectedNodeKey(null)}
|
||||
onUpdateConfig={onSelectedUpdateConfig}
|
||||
onDelete={handleDeleteSelected}
|
||||
onSetEntry={handleSetEntry}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Side panel — opens when a canvas node is clicked. Mounts the
|
||||
// shared NodeConfigForm dispatcher so edits made here behave
|
||||
// identically to the list view's per-card editor.
|
||||
// ============================================================
|
||||
|
||||
function NodeEditSheet({
|
||||
node,
|
||||
isEntry,
|
||||
allNodes,
|
||||
onClose,
|
||||
onUpdateConfig,
|
||||
onDelete,
|
||||
onSetEntry,
|
||||
}: {
|
||||
node: BuilderNode | null;
|
||||
isEntry: boolean;
|
||||
allNodes: BuilderNode[];
|
||||
onClose: () => void;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
onDelete: () => void;
|
||||
onSetEntry: () => void;
|
||||
}) {
|
||||
// Sheet is controlled — opens when a node is selected, closes via
|
||||
// Esc / overlay / close button (all delegated to onClose).
|
||||
const open = node !== null;
|
||||
if (!node) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md" />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
const meta = NODE_META[node.node_type];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-l border-slate-800 bg-slate-950 p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="border-b border-slate-800 px-5 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-slate-100">
|
||||
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
||||
<span>{meta.label}</span>
|
||||
{isEntry && (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
Entry
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-[11px] text-slate-400">
|
||||
{node.node_key}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
|
||||
<NodeConfigForm
|
||||
node={node}
|
||||
allNodes={allNodes}
|
||||
showAdvanced={false}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t border-slate-800 px-5 py-3 sm:flex-row sm:justify-between">
|
||||
{!isEntry ? (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
Set as entry
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<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 node
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import { LayoutGrid, ListTree } from "lucide-react";
|
||||
|
||||
import { FlowBuilder } from "./flow-builder";
|
||||
import { FlowCanvas } from "./flow-canvas";
|
||||
import { FlowEditorProvider } from "./flow-editor-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||
|
||||
@ -59,34 +60,32 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{view === "canvas" ? (
|
||||
<FlowCanvas initialFlow={initialFlow} initialNodes={initialNodes} />
|
||||
) : (
|
||||
<FlowBuilder initialFlow={initialFlow} initialNodes={initialNodes} />
|
||||
)}
|
||||
</div>
|
||||
{view === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||
</div>
|
||||
</FlowEditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
91
src/components/flows/flow-editor-state.test.ts
Normal file
91
src/components/flows/flow-editor-state.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
defaultConfigFor,
|
||||
uniqueNodeKey,
|
||||
} from "./flow-editor-state";
|
||||
import type { BuilderNode, NodeType } from "./shared";
|
||||
|
||||
describe("uniqueNodeKey", () => {
|
||||
it("returns the base key when it isn't taken", () => {
|
||||
expect(uniqueNodeKey("menu", [])).toBe("menu");
|
||||
expect(
|
||||
uniqueNodeKey("menu", [
|
||||
{ node_key: "other", node_type: "end", config: {} },
|
||||
]),
|
||||
).toBe("menu");
|
||||
});
|
||||
|
||||
it("appends _2 when the base is taken", () => {
|
||||
expect(
|
||||
uniqueNodeKey("menu", [
|
||||
{ node_key: "menu", node_type: "end", config: {} },
|
||||
]),
|
||||
).toBe("menu_2");
|
||||
});
|
||||
|
||||
it("walks forward until it finds an unused suffix", () => {
|
||||
const existing: BuilderNode[] = [
|
||||
{ node_key: "menu", node_type: "end", config: {} },
|
||||
{ node_key: "menu_2", node_type: "end", config: {} },
|
||||
{ node_key: "menu_3", node_type: "end", config: {} },
|
||||
];
|
||||
expect(uniqueNodeKey("menu", existing)).toBe("menu_4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultConfigFor", () => {
|
||||
// The hook's addNode and the validator both depend on these defaults
|
||||
// being self-consistent. A broken default would surface as a
|
||||
// validation error on a freshly-added node, which is exactly what
|
||||
// these snapshots guard against.
|
||||
const types: NodeType[] = [
|
||||
"start",
|
||||
"send_message",
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"send_media",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"end",
|
||||
];
|
||||
|
||||
it("returns an object for every known node type", () => {
|
||||
for (const t of types) {
|
||||
expect(typeof defaultConfigFor(t)).toBe("object");
|
||||
}
|
||||
});
|
||||
|
||||
it("send_buttons default has at least one button row", () => {
|
||||
const cfg = defaultConfigFor("send_buttons") as {
|
||||
buttons?: Array<{ reply_id: string; title: string }>;
|
||||
};
|
||||
expect(cfg.buttons?.length).toBeGreaterThan(0);
|
||||
expect(cfg.buttons?.[0].reply_id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("send_list default has at least one section with one row", () => {
|
||||
const cfg = defaultConfigFor("send_list") as {
|
||||
sections?: Array<{ rows: unknown[] }>;
|
||||
};
|
||||
expect(cfg.sections?.length).toBeGreaterThan(0);
|
||||
expect(cfg.sections?.[0].rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("send_media defaults to image (the most common case)", () => {
|
||||
const cfg = defaultConfigFor("send_media") as { media_type?: string };
|
||||
expect(cfg.media_type).toBe("image");
|
||||
});
|
||||
|
||||
it("collect_input ships a valid var_key that passes the validator regex", () => {
|
||||
const cfg = defaultConfigFor("collect_input") as { var_key?: string };
|
||||
// Mirrors the regex in validate.ts: alphanumeric + underscore,
|
||||
// starts with letter or underscore.
|
||||
expect(cfg.var_key).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
|
||||
});
|
||||
|
||||
it("end's default is an empty object (terminal — no config)", () => {
|
||||
expect(defaultConfigFor("end")).toEqual({});
|
||||
});
|
||||
});
|
||||
479
src/components/flows/flow-editor-state.tsx
Normal file
479
src/components/flows/flow-editor-state.tsx
Normal file
@ -0,0 +1,479 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Single source of truth for the flow editor's state.
|
||||
*
|
||||
* Both views (list and canvas) read and mutate the same `BuilderState`
|
||||
* via `useFlowEditor()`. The provider mounts once inside
|
||||
* `FlowEditorShell`, so toggling views never resets unsaved edits.
|
||||
*
|
||||
* What lives here:
|
||||
* - `BuilderState` shape (header fields, trigger config, nodes).
|
||||
* - Dirty / saving / activating flags so the header save button
|
||||
* and the beforeunload guard share the same source.
|
||||
* - All mutations: name / description / trigger / fallback,
|
||||
* addNode / updateNode / updateNodeConfig / updateNodePosition /
|
||||
* removeNode, setEntryNodeId.
|
||||
* - Side effects: save (PUT), setStatus (POST /activate),
|
||||
* deleteFlow (DELETE then router.push).
|
||||
* - Validation issues + the canActivate boolean.
|
||||
*
|
||||
* What does NOT live here:
|
||||
* - List-view UI state (expanded card set, scroll refs,
|
||||
* flash-on-jump) — those are list-only and stay in
|
||||
* `flow-builder.tsx`.
|
||||
* - Canvas-view UI state (selected node id, side-sheet open) —
|
||||
* those are canvas-only and stay in `flow-canvas.tsx`.
|
||||
*
|
||||
* `removeNode` does NOT auto-clean inbound edges. The list-view's
|
||||
* NodeKeySelect dropdowns and the validator both surface dangling
|
||||
* `next_node_key` references; that visibility is enough for v1. PR 2b
|
||||
* (canvas delete via keyboard) will revisit if the canvas adds an
|
||||
* implicit-delete affordance that's easier to trip accidentally.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
validateFlowForActivation,
|
||||
type ValidationIssue,
|
||||
} from "@/lib/flows/validate";
|
||||
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
|
||||
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
|
||||
|
||||
// ============================================================
|
||||
// State shape
|
||||
// ============================================================
|
||||
|
||||
export interface BuilderState {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: "keyword" | "first_inbound_message" | "manual";
|
||||
trigger_config: Record<string, unknown>;
|
||||
entry_node_id: string | null;
|
||||
status: FlowRow["status"];
|
||||
nodes: BuilderNode[];
|
||||
}
|
||||
|
||||
export interface FlowEditorContextValue {
|
||||
/** Immutable post-load envelope: id, created_at, fallback_policy, etc. */
|
||||
flow: FlowRow;
|
||||
|
||||
// Authored state
|
||||
state: BuilderState;
|
||||
/**
|
||||
* Dirty-tracking React setState. Flips `dirty` on every call. Used
|
||||
* by the list view's existing subcomponents (Header, TriggerPanel,
|
||||
* EntryPicker) which mutate multiple fields atomically — granular
|
||||
* setters below would force them to fan out the update.
|
||||
*/
|
||||
setState: (
|
||||
updaterOrValue:
|
||||
| BuilderState
|
||||
| ((prev: BuilderState) => BuilderState),
|
||||
) => void;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
activating: boolean;
|
||||
issues: ValidationIssue[];
|
||||
canActivate: boolean;
|
||||
|
||||
// Node mutations. addNode returns the generated key so the caller
|
||||
// (a NodeCard "Add" button or canvas "+" button) can scroll to /
|
||||
// focus / open the new node.
|
||||
addNode: (type: NodeType) => string;
|
||||
updateNode: (key: string, patch: Partial<BuilderNode>) => void;
|
||||
updateNodeConfig: (key: string, patch: Record<string, unknown>) => void;
|
||||
updateNodePosition: (key: string, x: number, y: number) => void;
|
||||
removeNode: (key: string) => void;
|
||||
|
||||
// Actions
|
||||
save: () => Promise<void>;
|
||||
setStatus: (status: BuilderState["status"]) => Promise<void>;
|
||||
deleteFlow: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers — node_key generation + per-type default configs
|
||||
// ============================================================
|
||||
|
||||
export function uniqueNodeKey(base: string, existing: BuilderNode[]): string {
|
||||
if (!existing.some((n) => n.node_key === base)) return base;
|
||||
let i = 2;
|
||||
while (existing.some((n) => n.node_key === `${base}_${i}`)) i += 1;
|
||||
return `${base}_${i}`;
|
||||
}
|
||||
|
||||
export function defaultConfigFor(type: NodeType): Record<string, unknown> {
|
||||
switch (type) {
|
||||
case "start":
|
||||
return { next_node_key: "" };
|
||||
case "send_message":
|
||||
return { text: "", next_node_key: "" };
|
||||
case "send_buttons":
|
||||
return {
|
||||
text: "",
|
||||
buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }],
|
||||
};
|
||||
case "send_list":
|
||||
return {
|
||||
text: "",
|
||||
button_label: "View options",
|
||||
sections: [
|
||||
{
|
||||
title: "",
|
||||
rows: [
|
||||
{ reply_id: "row_1", title: "Option 1", next_node_key: "" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "send_media":
|
||||
return {
|
||||
media_type: "image",
|
||||
media_url: "",
|
||||
caption: "",
|
||||
filename: "",
|
||||
next_node_key: "",
|
||||
};
|
||||
case "collect_input":
|
||||
return {
|
||||
prompt_text: "",
|
||||
var_key: "answer",
|
||||
next_node_key: "",
|
||||
};
|
||||
case "condition":
|
||||
return {
|
||||
subject: "var",
|
||||
subject_key: "",
|
||||
operator: "equals",
|
||||
value: "",
|
||||
true_next: "",
|
||||
false_next: "",
|
||||
};
|
||||
case "set_tag":
|
||||
return { mode: "add", tag_id: "", next_node_key: "" };
|
||||
case "handoff":
|
||||
return { note: "" };
|
||||
case "end":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Context
|
||||
// ============================================================
|
||||
|
||||
const FlowEditorCtx = createContext<FlowEditorContextValue | null>(null);
|
||||
|
||||
export function useFlowEditor(): FlowEditorContextValue {
|
||||
const ctx = useContext(FlowEditorCtx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useFlowEditor must be called inside <FlowEditorProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider
|
||||
// ============================================================
|
||||
|
||||
interface ProviderProps {
|
||||
initialFlow: FlowRow;
|
||||
initialNodes: FlowNodeRow[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FlowEditorProvider({
|
||||
initialFlow,
|
||||
initialNodes,
|
||||
children,
|
||||
}: ProviderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [state, setStateRaw] = useState<BuilderState>(() => ({
|
||||
name: initialFlow.name,
|
||||
description: initialFlow.description ?? "",
|
||||
trigger_type: initialFlow.trigger_type,
|
||||
trigger_config: initialFlow.trigger_config as Record<string, unknown>,
|
||||
entry_node_id: initialFlow.entry_node_id,
|
||||
status: initialFlow.status,
|
||||
nodes: initialNodes.map((n) => ({
|
||||
node_key: n.node_key,
|
||||
node_type: n.node_type as NodeType,
|
||||
config: n.config as Record<string, unknown>,
|
||||
position_x: n.position_x,
|
||||
position_y: n.position_y,
|
||||
})),
|
||||
}));
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activating, setActivating] = useState(false);
|
||||
// dirty flips on user edits; status-only updates (after the activate
|
||||
// API succeeds) use setStateRaw so they don't falsely re-flag the
|
||||
// form as dirty.
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setState = useCallback<typeof setStateRaw>((updaterOrValue) => {
|
||||
setDirty(true);
|
||||
setStateRaw(updaterOrValue);
|
||||
}, []);
|
||||
|
||||
// Browser-level reload / tab-close / external-link guard. SPA
|
||||
// navigation (sidebar links, back button) isn't covered — Next 16
|
||||
// routes through the App Router and beforeunload doesn't fire on
|
||||
// client-side route changes. That's a follow-up; this catches the
|
||||
// accidental refresh / closed-window class of data loss.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
// Modern browsers ignore the return value but require something
|
||||
// truthy to actually show the native prompt.
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [dirty]);
|
||||
|
||||
// ---- Validation ----
|
||||
const issues = useMemo<ValidationIssue[]>(
|
||||
() =>
|
||||
validateFlowForActivation(
|
||||
{
|
||||
name: state.name,
|
||||
trigger_type: state.trigger_type,
|
||||
trigger_config: state.trigger_config,
|
||||
entry_node_id: state.entry_node_id,
|
||||
},
|
||||
state.nodes,
|
||||
),
|
||||
[state],
|
||||
);
|
||||
const canActivate = useMemo(
|
||||
() => issues.every((i) => i.severity !== "error"),
|
||||
[issues],
|
||||
);
|
||||
|
||||
// ---- Save (PUT) ----
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/flows/${initialFlow.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: state.name,
|
||||
description: state.description || null,
|
||||
trigger_type: state.trigger_type,
|
||||
trigger_config: state.trigger_config,
|
||||
entry_node_id: state.entry_node_id,
|
||||
nodes: state.nodes,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error ?? `Save failed: ${res.status}`);
|
||||
}
|
||||
setDirty(false);
|
||||
toast.success("Saved.");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Save failed";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [initialFlow.id, state]);
|
||||
|
||||
// ---- Activate / Pause / Archive ----
|
||||
const setStatus = useCallback(
|
||||
async (next: BuilderState["status"]) => {
|
||||
if (next === "active" && !canActivate) {
|
||||
toast.error("Fix the issues below before activating.");
|
||||
return;
|
||||
}
|
||||
setActivating(true);
|
||||
try {
|
||||
// Always save first so the activation validator sees the
|
||||
// latest state — the user shouldn't have to remember "save
|
||||
// then activate".
|
||||
if (next === "active") {
|
||||
await save();
|
||||
}
|
||||
const res = await fetch(`/api/flows/${initialFlow.id}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error ?? `Status update failed: ${res.status}`);
|
||||
}
|
||||
setStateRaw((s) => ({ ...s, status: next }));
|
||||
toast.success(
|
||||
next === "active"
|
||||
? "Flow activated."
|
||||
: next === "archived"
|
||||
? "Archived."
|
||||
: "Saved as draft.",
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Status update failed";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setActivating(false);
|
||||
}
|
||||
},
|
||||
[canActivate, save, initialFlow.id],
|
||||
);
|
||||
|
||||
// ---- Delete ----
|
||||
const deleteFlow = useCallback(async () => {
|
||||
const yes = window.confirm(
|
||||
`Delete "${state.name}"? Any active runs end immediately. This can't be undone.`,
|
||||
);
|
||||
if (!yes) return;
|
||||
try {
|
||||
const res = await fetch(`/api/flows/${initialFlow.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
router.push("/flows");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Delete failed";
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [initialFlow.id, router, state.name]);
|
||||
|
||||
// ---- Node mutations ----
|
||||
const updateNode = useCallback(
|
||||
(key: string, patch: Partial<BuilderNode>) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
nodes: s.nodes.map((n) =>
|
||||
n.node_key === key ? { ...n, ...patch } : n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateNodeConfig = useCallback(
|
||||
(key: string, configPatch: Record<string, unknown>) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
nodes: s.nodes.map((n) =>
|
||||
n.node_key === key
|
||||
? { ...n, config: { ...n.config, ...configPatch } }
|
||||
: n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateNodePosition = useCallback(
|
||||
(key: string, x: number, y: number) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
nodes: s.nodes.map((n) =>
|
||||
n.node_key === key
|
||||
? { ...n, position_x: Math.round(x), position_y: Math.round(y) }
|
||||
: n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: NodeType): string => {
|
||||
const meta = NODE_META[type];
|
||||
const base = slugify(meta.label, type);
|
||||
let createdKey = base;
|
||||
setState((s) => {
|
||||
const node_key = uniqueNodeKey(base, s.nodes);
|
||||
createdKey = node_key;
|
||||
const next: BuilderNode = {
|
||||
node_key,
|
||||
node_type: type,
|
||||
config: defaultConfigFor(type),
|
||||
};
|
||||
return {
|
||||
...s,
|
||||
nodes: [...s.nodes, next],
|
||||
// If this is the first node and it's a start, pick it as
|
||||
// the entry automatically. Saves a click.
|
||||
entry_node_id:
|
||||
s.entry_node_id ??
|
||||
(type === "start" ? node_key : s.entry_node_id ?? null),
|
||||
};
|
||||
});
|
||||
return createdKey;
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const removeNode = useCallback(
|
||||
(key: string) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
nodes: s.nodes.filter((n) => n.node_key !== key),
|
||||
entry_node_id: s.entry_node_id === key ? null : s.entry_node_id,
|
||||
}));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const value = useMemo<FlowEditorContextValue>(
|
||||
() => ({
|
||||
flow: initialFlow,
|
||||
state,
|
||||
setState,
|
||||
dirty,
|
||||
saving,
|
||||
activating,
|
||||
issues,
|
||||
canActivate,
|
||||
addNode,
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
removeNode,
|
||||
save,
|
||||
setStatus,
|
||||
deleteFlow,
|
||||
}),
|
||||
[
|
||||
initialFlow,
|
||||
state,
|
||||
setState,
|
||||
dirty,
|
||||
saving,
|
||||
activating,
|
||||
issues,
|
||||
canActivate,
|
||||
addNode,
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
removeNode,
|
||||
save,
|
||||
setStatus,
|
||||
deleteFlow,
|
||||
],
|
||||
);
|
||||
|
||||
return <FlowEditorCtx.Provider value={value}>{children}</FlowEditorCtx.Provider>;
|
||||
}
|
||||
134
src/components/flows/forms/fields.tsx
Normal file
134
src/components/flows/forms/fields.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Reusable field components shared across every per-node form.
|
||||
*
|
||||
* `NodeKeySelect` — picks a node from the flow's node list, rendered
|
||||
* with the source node's icon so the dropdown reads as
|
||||
* "destination = ◇ menu" rather than an opaque slug.
|
||||
*
|
||||
* `NextNodeRow` — wraps NodeKeySelect with a label; the most common
|
||||
* per-node form row ("after this node, advance to…").
|
||||
*
|
||||
* `TextRow` — wraps Input or Textarea behind a label. Pure UI sugar
|
||||
* to keep per-node forms uncluttered.
|
||||
*
|
||||
* Lives in src/components/flows/forms/ so both the list view's
|
||||
* collapsed-card editor and the canvas view's side-panel editor
|
||||
* (introduced in this PR) mount the exact same form components.
|
||||
*/
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NODE_META, type BuilderNode } from "../shared";
|
||||
|
||||
export function TextRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 1,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">{label}</label>
|
||||
{rows > 1 ? (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
className="bg-slate-800"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-slate-800"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NextNodeRow({
|
||||
value,
|
||||
allNodes,
|
||||
currentKey,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">{label}</label>
|
||||
<NodeKeySelect
|
||||
value={value || null}
|
||||
nodes={allNodes}
|
||||
excludeKey={currentKey}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
placeholder="Pick a next node…"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeKeySelect({
|
||||
value,
|
||||
nodes,
|
||||
excludeKey,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
nodes: BuilderNode[];
|
||||
excludeKey?: string;
|
||||
onChange: (v: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const options = nodes.filter((n) => n.node_key !== excludeKey);
|
||||
return (
|
||||
<Select
|
||||
value={value ?? "__none__"}
|
||||
onValueChange={(v) => onChange(v === "__none__" ? null : v)}
|
||||
>
|
||||
<SelectTrigger className={cn("bg-slate-800", className)}>
|
||||
<SelectValue placeholder={placeholder ?? "—"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— None —</SelectItem>
|
||||
{options.map((n) => {
|
||||
const Icon = NODE_META[n.node_type].icon;
|
||||
return (
|
||||
<SelectItem key={n.node_key} value={n.node_key}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={cn("h-3 w-3", NODE_META[n.node_type].color)}
|
||||
/>
|
||||
{n.node_key}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
1076
src/components/flows/forms/node-config-form.tsx
Normal file
1076
src/components/flows/forms/node-config-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -113,6 +113,25 @@ export const NODE_META: Record<
|
||||
end: { label: "End", icon: Flag, color: "text-slate-400" },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Pure editing helpers — used by forms in both views.
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary string into a stable identifier (node_key,
|
||||
* reply_id, etc.). Lowercases, collapses non-alphanumerics into
|
||||
* single underscores, and trims leading/trailing underscores. Falls
|
||||
* back to `fallback` for inputs that reduce to an empty string.
|
||||
*/
|
||||
export function slugify(s: string, fallback: string): string {
|
||||
const cleaned = s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Summary helpers — short, single-line content previews used in
|
||||
// collapsed node cards (list view) and node tiles (canvas view).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user