Merge pull request #159 from ArnasDon/feat/flow-canvas-editable-pt1

feat(flows): canvas / mind-map view, PR 2a of 3 — drag-to-position + side-panel edit
This commit is contained in:
Arnas Donauskas 2026-05-29 20:19:31 +02:00 committed by GitHub
commit 90d18c8dbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 2065 additions and 1473 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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({});
});
});

View 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>;
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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).