feat(frontend): editor scaffold — graph adapter + React Flow canvas

This commit is contained in:
Claude
2026-08-28 16:21:33 +00:00
parent c9193dbf36
commit 891ff6a722
6 changed files with 204 additions and 1 deletions

View File

@@ -0,0 +1,54 @@
import { useCallback } from "react";
import {
ReactFlow, Background, Controls, addEdge,
useNodesState, useEdgesState, type Connection, type Edge, type Node,
} from "@xyflow/react";
import { nodeTypes } from "./customNode";
import type { VcNodeData } from "./graphAdapter";
interface Props {
initialNodes: Node<VcNodeData>[];
initialEdges: Edge[];
onNodesChange?: (nodes: Node<VcNodeData>[]) => void;
onEdgesChangeOut?: (edges: Edge[]) => void;
onSelectNode?: (id: string | null) => void;
}
export function Canvas({ initialNodes, initialEdges, onNodesChange, onEdgesChangeOut, onSelectNode }: Props) {
const [nodes, , onNodesChangeInternal] = useNodesState<Node<VcNodeData>>(initialNodes);
const [edges, setEdges, onEdgesChangeInternal] = useEdgesState<Edge>(initialEdges);
const onConnect = useCallback(
(c: Connection) => setEdges((eds) => {
const next = addEdge({ ...c }, eds);
onEdgesChangeOut?.(next);
return next;
}),
[setEdges, onEdgesChangeOut],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={(changes) => {
onNodesChangeInternal(changes);
queueMicrotask(() => onNodesChange?.(nodes));
}}
onEdgesChange={(changes) => {
onEdgesChangeInternal(changes);
queueMicrotask(() => onEdgesChangeOut?.(edges));
}}
onConnect={onConnect}
onSelectionChange={({ nodes: sel }) => onSelectNode?.(sel[0]?.id ?? null)}
nodeTypes={nodeTypes}
fitView
>
<Background />
<Controls />
</ReactFlow>
);
}
// Re-export helpers used by parents.
export { setNodesHelper, setEdgesHelper } from "./canvasHelpers";

View File

@@ -0,0 +1,6 @@
// Small utilities for parent-owned graph state. Kept separate so the Canvas
// component's re-export list stays clean.
import type { Edge, Node } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
export function setNodesHelper(n: Node<VcNodeData>[]) { return n; }
export function setEdgesHelper(e: Edge[]) { return e; }

View File

@@ -0,0 +1,30 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
import { useNodeTypes } from "@/api/hooks";
export function VcNode(props: NodeProps) {
const data = props.data as VcNodeData;
const nts = useNodeTypes();
const nt = nts.data?.find((t) => t.id === data.typeId);
const inputs = nt?.inputCount ?? 1;
const outputs = nt?.outputCount ?? 1;
return (
<div className="rounded border border-slate-700 bg-slate-900 min-w-[160px] shadow">
{Array.from({ length: inputs }, (_, i) => (
<Handle key={`i${i}`} type="target" position={Position.Left}
id={`i-${i}`} style={{ top: 20 + i * 16, background: "#5b6cff" }} />
))}
<div className="px-3 py-2 border-b border-slate-800 text-xs text-slate-400 uppercase">
{nt?.category ?? "…"}
</div>
<div className="px-3 py-2 text-sm text-slate-100">{data.name ?? nt?.displayName ?? data.typeId}</div>
{Array.from({ length: outputs }, (_, i) => (
<Handle key={`o${i}`} type="source" position={Position.Right}
id={`o-${i}`} style={{ top: 20 + i * 16, background: "#5b6cff" }} />
))}
</div>
);
}
export const nodeTypes = { vc: VcNode };

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { fromGraph, toGraph } from "./graphAdapter";
import type { WorkflowGraph } from "@/api/types";
describe("graphAdapter", () => {
const graph: WorkflowGraph = {
nodes: [
{ id: "a", type_id: "http.webhook", position: { x: 10, y: 20 }, config: { path: "/x" } },
{ id: "b", type_id: "nats.publish", position: { x: 200, y: 20 }, config: { subject: "y" }, connection_id: "c1" },
],
edges: [{ from_node: "a", from_port: 0, to_node: "b", to_port: 0 }],
};
it("fromGraph maps nodes + edges to React Flow shape", () => {
const { nodes, edges } = fromGraph(graph);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({ id: "a", position: { x: 10, y: 20 }, type: "vc" });
expect((nodes[0].data as { typeId: string }).typeId).toBe("http.webhook");
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({ source: "a", target: "b" });
});
it("toGraph is the inverse", () => {
const rf = fromGraph(graph);
const roundTripped = toGraph(rf.nodes, rf.edges);
expect(roundTripped).toEqual(graph);
});
});

View File

@@ -0,0 +1,58 @@
import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode, WorkflowGraph } from "@/api/types";
export interface VcNodeData extends Record<string, unknown> {
typeId: string;
name?: string;
config: Record<string, unknown>;
connection_id?: string | null;
}
export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; edges: Edge[] } {
const nodes: Node<VcNodeData>[] = g.nodes.map((n) => ({
id: n.id,
type: "vc",
position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 },
data: {
typeId: n.type_id,
name: n.name,
config: n.config ?? {},
connection_id: n.connection_id ?? null,
},
}));
const edges: Edge[] = g.edges.map((e) => ({
id: `${e.from_node}:${e.from_port}->${e.to_node}:${e.to_port}`,
source: e.from_node,
sourceHandle: `o-${e.from_port}`,
target: e.to_node,
targetHandle: `i-${e.to_port}`,
}));
return { nodes, edges };
}
export function toGraph(nodes: Node<VcNodeData>[], edges: Edge[]): WorkflowGraph {
const gNodes: GraphNode[] = nodes.map((n) => {
const node: GraphNode = {
id: n.id,
type_id: n.data.typeId,
position: { x: n.position.x, y: n.position.y },
config: n.data.config ?? {},
};
if (n.data.name !== undefined) node.name = n.data.name;
if (n.data.connection_id != null) node.connection_id = n.data.connection_id;
return node;
});
const gEdges: GraphEdge[] = edges.map((e) => ({
from_node: e.source,
from_port: parseHandlePort(e.sourceHandle, "o"),
to_node: e.target,
to_port: parseHandlePort(e.targetHandle, "i"),
}));
return { nodes: gNodes, edges: gEdges };
}
function parseHandlePort(handle: string | null | undefined, prefix: "i" | "o"): number {
if (!handle) return 0;
const m = new RegExp(`^${prefix}-(\\d+)$`).exec(handle);
return m ? Number(m[1]) : 0;
}

View File

@@ -1,3 +1,30 @@
import { useParams } from "react-router-dom";
import { useMemo } from "react";
import { useWorkflow } from "@/api/hooks";
import { Canvas } from "@/editor/Canvas";
import { fromGraph } from "@/editor/graphAdapter";
export default function EditorPage() {
return <div className="p-8">Editor stub — replaced Task 12.</div>;
const { id } = useParams();
const wf = useWorkflow(id);
const initial = useMemo(
() => (wf.data ? fromGraph(wf.data.graph) : { nodes: [], edges: [] }),
[wf.data],
);
if (wf.isLoading || !wf.data) {
return <div className="p-6 text-slate-400 text-sm">Loading…</div>;
}
return (
<div className="h-screen flex flex-col">
<div className="border-b border-slate-800 px-4 py-2 flex items-center gap-3">
<span className="font-semibold">{wf.data.name}</span>
<span className="text-xs text-slate-500">Editor</span>
</div>
<div className="flex-1">
<Canvas initialNodes={initial.nodes} initialEdges={initial.edges} />
</div>
</div>
);
}