mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
90d18c8dbd
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,7 @@
|
|||||||
* that has to stay stable across views.
|
* that has to stay stable across views.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
@ -36,24 +36,30 @@ import {
|
|||||||
type Node as RfNode,
|
type Node as RfNode,
|
||||||
type Edge as RfEdge,
|
type Edge as RfEdge,
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
|
type OnNodeDrag,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
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 { deriveCanvasEdges } from "@/lib/flows/edges";
|
||||||
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
||||||
import {
|
import {
|
||||||
NODE_META,
|
NODE_META,
|
||||||
summarizeNode,
|
summarizeNode,
|
||||||
type BuilderNode,
|
type BuilderNode,
|
||||||
type NodeType,
|
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
|
import { useFlowEditor } from "./flow-editor-state";
|
||||||
|
import { NodeConfigForm } from "./forms/node-config-form";
|
||||||
interface FlowCanvasProps {
|
|
||||||
initialFlow: FlowRow;
|
|
||||||
initialNodes: FlowNodeRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// React-Flow node `data` payload — the bits our custom renderer needs.
|
// React-Flow node `data` payload — the bits our custom renderer needs.
|
||||||
interface NodeData extends Record<string, unknown> {
|
interface NodeData extends Record<string, unknown> {
|
||||||
@ -115,16 +121,30 @@ const NODE_TYPES = { flow: FlowNodeCard };
|
|||||||
// Root canvas
|
// Root canvas
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
export function FlowCanvas() {
|
||||||
const { rfNodes, rfEdges } = useMemo(() => {
|
const {
|
||||||
const builderNodes: BuilderNode[] = initialNodes.map((n) => ({
|
state,
|
||||||
node_key: n.node_key,
|
setState,
|
||||||
node_type: n.node_type as NodeType,
|
updateNodeConfig,
|
||||||
config: n.config as Record<string, unknown>,
|
updateNodePosition,
|
||||||
position_x: n.position_x,
|
removeNode,
|
||||||
position_y: n.position_y,
|
} = 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);
|
const canvasEdges = deriveCanvasEdges(builderNodes);
|
||||||
|
|
||||||
// Decide whether to auto-layout. The helper guards against
|
// Decide whether to auto-layout. The helper guards against
|
||||||
@ -155,12 +175,10 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
node: n,
|
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
|
// Drag-to-connect + delete-key still off in PR 2a — those land
|
||||||
// still allowed visually because disabling it makes the canvas
|
// in PR 2b with per-slot handles + cascading edge cleanup.
|
||||||
// feel inert in a way that's worse than letting users nudge a
|
|
||||||
// tile that won't save.
|
|
||||||
connectable: false,
|
connectable: false,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
};
|
};
|
||||||
@ -183,7 +201,49 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return { rfNodes, rfEdges };
|
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) {
|
if (rfNodes.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -203,8 +263,10 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
|||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
|
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
// Read-only knobs — keeps PR 1's surface inert at the
|
onNodeDragStop={handleNodeDragStop}
|
||||||
// React-Flow level. PR 2 will flip these back on.
|
onNodeClick={handleNodeClick}
|
||||||
|
// Drag-to-connect + delete-by-keyboard still off — both land
|
||||||
|
// in PR 2b with per-slot handles + cascading edge cleanup.
|
||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
edgesFocusable={false}
|
edgesFocusable={false}
|
||||||
elementsSelectable={true}
|
elementsSelectable={true}
|
||||||
@ -228,6 +290,104 @@ export function FlowCanvas({ initialFlow, initialNodes }: FlowCanvasProps) {
|
|||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NodeEditSheet
|
||||||
|
node={selectedNode}
|
||||||
|
isEntry={selectedNode?.node_key === entryNodeId}
|
||||||
|
allNodes={builderNodes}
|
||||||
|
onClose={() => setSelectedNodeKey(null)}
|
||||||
|
onUpdateConfig={onSelectedUpdateConfig}
|
||||||
|
onDelete={handleDeleteSelected}
|
||||||
|
onSetEntry={handleSetEntry}
|
||||||
|
/>
|
||||||
</ReactFlowProvider>
|
</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 { FlowBuilder } from "./flow-builder";
|
||||||
import { FlowCanvas } from "./flow-canvas";
|
import { FlowCanvas } from "./flow-canvas";
|
||||||
|
import { FlowEditorProvider } from "./flow-editor-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||||
|
|
||||||
@ -59,34 +60,32 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex flex-col gap-3">
|
||||||
<div
|
<div className="flex items-center justify-end">
|
||||||
role="group"
|
<div
|
||||||
aria-label="Editor view"
|
role="group"
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
|
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"}
|
<ToggleButton
|
||||||
onClick={() => choose("canvas")}
|
active={view === "canvas"}
|
||||||
icon={<LayoutGrid className="h-3 w-3" />}
|
onClick={() => choose("canvas")}
|
||||||
label="Canvas"
|
icon={<LayoutGrid className="h-3 w-3" />}
|
||||||
/>
|
label="Canvas"
|
||||||
<ToggleButton
|
/>
|
||||||
active={view === "list"}
|
<ToggleButton
|
||||||
onClick={() => choose("list")}
|
active={view === "list"}
|
||||||
icon={<ListTree className="h-3 w-3" />}
|
onClick={() => choose("list")}
|
||||||
label="List"
|
icon={<ListTree className="h-3 w-3" />}
|
||||||
/>
|
label="List"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{view === "canvas" ? (
|
{view === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||||
<FlowCanvas initialFlow={initialFlow} initialNodes={initialNodes} />
|
</div>
|
||||||
) : (
|
</FlowEditorProvider>
|
||||||
<FlowBuilder initialFlow={initialFlow} initialNodes={initialNodes} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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" },
|
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
|
// Summary helpers — short, single-line content previews used in
|
||||||
// collapsed node cards (list view) and node tiles (canvas view).
|
// collapsed node cards (list view) and node tiles (canvas view).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user