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