wacrm/src/components/flows/flow-editor-state.tsx
Arnas Donauskas dcdfff938d
feat(flows): canvas / mind-map view (PR 2b of 3 — full editing)
Closes the editing-affordance gap from PR 2a. The canvas is now in
full parity with the list view for everything that fits in a node
graph: connections, deletes, and adding nodes all work on-canvas.

Adds:

  - Per-slot Handle components on the custom node renderer. Single-
    outgoing types get one handle on the right edge; condition gets
    `true` / `false` handles; send_buttons and send_list render slot
    rows inside the card with one handle each, so the user can SEE
    which button or list row they're wiring. Every non-start node
    gets a target handle on the left.

  - Drag-to-connect (`onConnect`). Looks up the source node, dispatches
    the right config patch via the new `applyEdgeConnection(node,
    sourceHandle, target)` helper, and routes through updateNodeConfig.
    Rejects self-loops silently (a button whose target is its own node
    = infinite reprompt).

  - Keyboard delete on nodes (Backspace + Delete, both for cross-OS
    parity). Routes through removeNode, which now ALSO unlinks every
    inbound reference via the new `unlinkNodeReferences(nodes, key)`
    helper. No more dangling arrows after a delete — the validator
    previously flagged these as errors and the user had to chase them
    down manually.

  - Edge delete. Selecting an edge + Delete clears just that slot's
    next_node_key (rather than removing anything), via the same
    applyEdgeConnection helper passing "" as the target.

  - Floating "+ Add node" button (React-Flow Panel, bottom-right).
    Same dropdown as the list view; drops the new node at the visible
    viewport center via reactFlow.screenToFlowPosition.

New pure helpers in src/lib/flows/edges.ts (no React, fully unit-
tested — 18 new tests, 319 total passing):

  - outgoingSlots(node) — list of {id, label} per outgoing slot.
  - applyEdgeConnection(node, sourceHandle, targetKey) — config patch
    for a drag-to-connect, or null for unrecognised handles.
  - unlinkNodeReferences(nodes, deletedKey) — clears every direct +
    nested reference to deletedKey. Returns nodes-by-identity when
    no reference matches to avoid needless re-renders.

The hook's removeNode is the only consumer of unlinkNodeReferences
right now, which means LIST-view deletes also stop leaving dangling
refs — strict improvement for both views.

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

488 lines
14 KiB
TypeScript

"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 { unlinkNodeReferences } from "@/lib/flows/edges";
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) => {
// Auto-unlink inbound references so canvas / list deletes don't
// leave dangling arrows behind that the validator would flag.
// Cleared refs become "" (the "no target picked" sentinel the
// builder forms already use).
setState((s) => ({
...s,
nodes: unlinkNodeReferences(
s.nodes.filter((n) => n.node_key !== 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>;
}