feat(frontend): editor scaffold — graph adapter + React Flow canvas
This commit is contained in:
54
frontend/src/editor/Canvas.tsx
Normal file
54
frontend/src/editor/Canvas.tsx
Normal 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";
|
||||
6
frontend/src/editor/canvasHelpers.ts
Normal file
6
frontend/src/editor/canvasHelpers.ts
Normal 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; }
|
||||
30
frontend/src/editor/customNode.tsx
Normal file
30
frontend/src/editor/customNode.tsx
Normal 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 };
|
||||
28
frontend/src/editor/graphAdapter.test.ts
Normal file
28
frontend/src/editor/graphAdapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
frontend/src/editor/graphAdapter.ts
Normal file
58
frontend/src/editor/graphAdapter.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user