mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
827 lines
25 KiB
TypeScript
827 lines
25 KiB
TypeScript
import React, { useCallback, useMemo, useEffect } from "react";
|
|
import {
|
|
Box,
|
|
VStack,
|
|
HStack,
|
|
Heading,
|
|
Text,
|
|
Button,
|
|
Separator,
|
|
} from "@chakra-ui/react";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import ReactFlow, {
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
Node,
|
|
Edge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
addEdge,
|
|
Connection,
|
|
ConnectionMode,
|
|
Handle,
|
|
Position,
|
|
} from "reactflow";
|
|
import dagre from "dagre";
|
|
import "reactflow/dist/style.css";
|
|
import { useFlowClasses } from "@trustgraph/react-state";
|
|
import serviceMap from "../../data/service-map.json";
|
|
|
|
interface FlowClassEditorViewProps {
|
|
flowClassId: string;
|
|
onBack: () => void;
|
|
}
|
|
|
|
interface ProcessorInfo {
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Custom node component - use role for connections, direction for positioning
|
|
const CustomNode = ({
|
|
data,
|
|
}: {
|
|
data: {
|
|
label: string;
|
|
type?: string;
|
|
provides?: string[];
|
|
consumes?: string[];
|
|
processorInfo?: ProcessorInfo;
|
|
};
|
|
}) => {
|
|
const borderColor = data.type === "class" ? "#2563eb" : "#16a34a"; // blue for class, green for flow
|
|
const backgroundColor = data.type === "class" ? "#eff6ff" : "#f0fdf4";
|
|
|
|
const provides = data.provides || [];
|
|
const consumes = data.consumes || [];
|
|
const processorInfo = data.processorInfo || { connections: [] };
|
|
|
|
// Group connections by direction for positioning
|
|
const leftConnections: string[] = [];
|
|
const rightConnections: string[] = [];
|
|
|
|
interface Connection {
|
|
name: string;
|
|
role: string;
|
|
direction?: string;
|
|
}
|
|
|
|
// Add provides connections to left or right based on direction
|
|
provides.forEach((connectionName) => {
|
|
const conn = (processorInfo.connections as Connection[] | undefined)?.find(
|
|
(c) => c.name === connectionName && c.role === "provides",
|
|
);
|
|
if (conn?.direction === "in") {
|
|
leftConnections.push(connectionName);
|
|
} else {
|
|
rightConnections.push(connectionName);
|
|
}
|
|
});
|
|
|
|
// Add consumes connections to left or right based on direction
|
|
consumes.forEach((connectionName) => {
|
|
const conn = (processorInfo.connections as Connection[] | undefined)?.find(
|
|
(c) => c.name === connectionName && c.role === "consumes",
|
|
);
|
|
if (conn?.direction === "in") {
|
|
leftConnections.push(connectionName);
|
|
} else {
|
|
rightConnections.push(connectionName);
|
|
}
|
|
});
|
|
|
|
const nodeHeight = Math.max(
|
|
50,
|
|
Math.max(leftConnections.length, rightConnections.length) * 25 + 30,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: "10px 20px",
|
|
border: `2px solid ${borderColor}`,
|
|
borderRadius: "6px",
|
|
background: backgroundColor,
|
|
fontSize: "14px",
|
|
fontWeight: "500",
|
|
position: "relative",
|
|
minWidth: "150px",
|
|
minHeight: `${nodeHeight}px`,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{/* LEFT side connections */}
|
|
{leftConnections.map((connection, index) => {
|
|
const conn = (
|
|
processorInfo.connections as Connection[] | undefined
|
|
)?.find((c) => c.name === connection);
|
|
const isProvides = conn?.role === "provides";
|
|
return (
|
|
<React.Fragment
|
|
key={`${isProvides ? "provide" : "consume"}-${connection}`}
|
|
>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id={`${isProvides ? "provide" : "consume"}-${connection}`}
|
|
style={{
|
|
background: isProvides ? "#16a34a" : "#dc2626",
|
|
top: `${((index + 1) / (leftConnections.length + 1)) * 100}%`,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
right: `calc(100% + 15px)`,
|
|
top: `calc(${((index + 1) / (leftConnections.length + 1)) * 100}% - 8px)`,
|
|
transform: "translateY(-50%)",
|
|
fontSize: "9px",
|
|
color: isProvides ? "#16a34a" : "#dc2626",
|
|
fontWeight: "normal",
|
|
whiteSpace: "nowrap",
|
|
textAlign: "right",
|
|
}}
|
|
>
|
|
{connection}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
|
|
{/* RIGHT side connections */}
|
|
{rightConnections.map((connection, index) => {
|
|
const conn = (
|
|
processorInfo.connections as Connection[] | undefined
|
|
)?.find((c) => c.name === connection);
|
|
const isProvides = conn?.role === "provides";
|
|
return (
|
|
<React.Fragment
|
|
key={`${isProvides ? "provide" : "consume"}-${connection}`}
|
|
>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id={`${isProvides ? "provide" : "consume"}-${connection}`}
|
|
style={{
|
|
background: isProvides ? "#16a34a" : "#dc2626",
|
|
top: `${((index + 1) / (rightConnections.length + 1)) * 100}%`,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: `calc(100% + 15px)`,
|
|
top: `calc(${((index + 1) / (rightConnections.length + 1)) * 100}% - 8px)`,
|
|
transform: "translateY(-50%)",
|
|
fontSize: "9px",
|
|
color: isProvides ? "#16a34a" : "#dc2626",
|
|
fontWeight: "normal",
|
|
whiteSpace: "nowrap",
|
|
textAlign: "left",
|
|
}}
|
|
>
|
|
{connection}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
|
|
<div style={{ fontSize: "12px", fontWeight: "600" }}>{data.label}</div>
|
|
{data.type && (
|
|
<div
|
|
style={{
|
|
fontSize: "10px",
|
|
color: borderColor,
|
|
fontWeight: "normal",
|
|
marginTop: "2px",
|
|
}}
|
|
>
|
|
{data.type}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Interface node component - visually distinct from processors
|
|
const InterfaceNode = ({
|
|
data,
|
|
}: {
|
|
data: {
|
|
label: string;
|
|
interfaceKind?: string;
|
|
description?: string;
|
|
visible?: boolean;
|
|
queues?: Record<string, unknown>;
|
|
};
|
|
}) => {
|
|
const borderColor = data.interfaceKind === "service" ? "#8b5cf6" : "#ec4899"; // purple for service, pink for flow
|
|
const backgroundColor =
|
|
data.interfaceKind === "service" ? "#f3e8ff" : "#fce7f3";
|
|
const icon = data.interfaceKind === "service" ? "⚡" : "📦";
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: "12px 20px",
|
|
border: `2px dashed ${borderColor}`,
|
|
borderRadius: "12px",
|
|
background: backgroundColor,
|
|
fontSize: "14px",
|
|
fontWeight: "500",
|
|
minWidth: "180px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{/* Connection handle on the right side */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id={`interface-${data.label}`}
|
|
style={{
|
|
background: borderColor,
|
|
width: "12px",
|
|
height: "12px",
|
|
border: "2px solid white",
|
|
right: "-6px",
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
fontSize: "16px",
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
<span>{icon}</span>
|
|
<span>{data.label}</span>
|
|
</div>
|
|
{data.description && (
|
|
<div
|
|
style={{
|
|
fontSize: "11px",
|
|
color: "#6b7280",
|
|
fontStyle: "italic",
|
|
textAlign: "center",
|
|
maxWidth: "200px",
|
|
}}
|
|
>
|
|
{data.description}
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
fontSize: "10px",
|
|
color: borderColor,
|
|
fontWeight: "bold",
|
|
textTransform: "uppercase",
|
|
marginTop: "4px",
|
|
}}
|
|
>
|
|
{data.interfaceKind} interface
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Register custom node types
|
|
const nodeTypes = {
|
|
custom: CustomNode,
|
|
interface: InterfaceNode,
|
|
};
|
|
|
|
interface FlowClass {
|
|
class?: Record<string, unknown>;
|
|
flow?: Record<string, unknown>;
|
|
}
|
|
|
|
// Generate nodes from flow class processors
|
|
const generateNodesFromFlowClass = (flowClass: FlowClass): Node[] => {
|
|
const nodes: Node[] = [];
|
|
|
|
// Add class processors
|
|
Object.keys(flowClass.class || {}).forEach((processorName) => {
|
|
// Strip template suffix to get base processor name for service map lookup
|
|
const baseProcessorName = processorName.replace(/:\{[^}]+\}$/, "");
|
|
|
|
// Get connection info from service map - use role for connections, direction for positioning
|
|
const processorInfo = serviceMap.processors[baseProcessorName] || {
|
|
connections: [],
|
|
};
|
|
const provides =
|
|
processorInfo.connections
|
|
?.filter((conn) => conn.role === "provides")
|
|
.map((conn) => conn.name) || [];
|
|
const consumes =
|
|
processorInfo.connections
|
|
?.filter((conn) => conn.role === "consumes")
|
|
.map((conn) => conn.name) || [];
|
|
|
|
nodes.push({
|
|
id: `class-${processorName}`,
|
|
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
|
data: {
|
|
label: processorName,
|
|
type: "class",
|
|
provides: provides,
|
|
consumes: consumes,
|
|
processorInfo: processorInfo, // Pass full processor info for direction lookup
|
|
},
|
|
type: "custom",
|
|
});
|
|
});
|
|
|
|
// Add flow processors
|
|
Object.keys(flowClass.flow || {}).forEach((processorName) => {
|
|
// Strip template suffix to get base processor name for service map lookup
|
|
const baseProcessorName = processorName.replace(/:\{[^}]+\}$/, "");
|
|
|
|
// Get connection info from service map - use role for connections, direction for positioning
|
|
const processorInfo = serviceMap.processors[baseProcessorName] || {
|
|
connections: [],
|
|
};
|
|
const provides =
|
|
processorInfo.connections
|
|
?.filter((conn) => conn.role === "provides")
|
|
.map((conn) => conn.name) || [];
|
|
const consumes =
|
|
processorInfo.connections
|
|
?.filter((conn) => conn.role === "consumes")
|
|
.map((conn) => conn.name) || [];
|
|
|
|
nodes.push({
|
|
id: `flow-${processorName}`,
|
|
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
|
data: {
|
|
label: processorName,
|
|
type: "flow",
|
|
provides: provides,
|
|
consumes: consumes,
|
|
processorInfo: processorInfo, // Pass full processor info for direction lookup
|
|
},
|
|
type: "custom",
|
|
});
|
|
});
|
|
|
|
// Add interface nodes
|
|
Object.entries(flowClass.interfaces || {}).forEach(
|
|
([interfaceName, interfaceQueues]) => {
|
|
// Look up interface definition in service map
|
|
const interfaceDefinition = serviceMap.interfaces?.[interfaceName];
|
|
|
|
nodes.push({
|
|
id: `interface-${interfaceName}`,
|
|
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
|
data: {
|
|
label: interfaceName,
|
|
type: "interface",
|
|
interfaceKind: interfaceDefinition?.kind || "unknown",
|
|
description: interfaceDefinition?.description || "",
|
|
visible: interfaceDefinition?.visible,
|
|
queues: interfaceQueues,
|
|
},
|
|
type: "interface", // Use a different node type for interfaces
|
|
});
|
|
},
|
|
);
|
|
|
|
return nodes;
|
|
};
|
|
|
|
// Apply dagre layout to nodes and edges for better positioning
|
|
const applyDagreLayout = (nodes: Node[], edges: Edge[]): Node[] => {
|
|
const nodeWidth = 200;
|
|
const nodeHeight = 120; // Increased for interface nodes
|
|
|
|
// Create a new directed graph
|
|
const dagreGraph = new dagre.graphlib.Graph();
|
|
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
dagreGraph.setGraph({
|
|
rankdir: "LR", // Left to right layout
|
|
nodesep: 80, // Increased horizontal spacing between nodes
|
|
ranksep: 500, // Extra 50% left-right spacing between ranks
|
|
marginx: 40, // Increased margins
|
|
marginy: 40,
|
|
align: "UL", // Align ranks upward-left for better interface positioning
|
|
acyclicer: "greedy", // Better cycle removal
|
|
ranker: "tight-tree", // Better ranking algorithm
|
|
});
|
|
|
|
// Add nodes to dagre graph
|
|
nodes.forEach((node) => {
|
|
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
});
|
|
|
|
// Add edges to dagre graph
|
|
edges.forEach((edge) => {
|
|
dagreGraph.setEdge(edge.source, edge.target);
|
|
});
|
|
|
|
// Calculate the layout
|
|
dagre.layout(dagreGraph);
|
|
|
|
// Apply the calculated positions back to the nodes
|
|
return nodes.map((node) => {
|
|
const nodeWithPosition = dagreGraph.node(node.id);
|
|
return {
|
|
...node,
|
|
position: {
|
|
x: nodeWithPosition.x - nodeWidth / 2,
|
|
y: nodeWithPosition.y - nodeHeight / 2,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
// Generate edges from flow class connections using three-way matching algorithm
|
|
const generateEdgesFromFlowClass = (flowClass: FlowClass): Edge[] => {
|
|
const edges: Edge[] = [];
|
|
let edgeIndex = 0;
|
|
|
|
// Build maps of providers and consumers by connection type
|
|
const providersByType = new Map<
|
|
string,
|
|
Array<{
|
|
processorId: string;
|
|
processorName: string;
|
|
connectionName: string;
|
|
queues: Record<string, unknown>;
|
|
}>
|
|
>();
|
|
const consumersByType = new Map<
|
|
string,
|
|
Array<{
|
|
processorId: string;
|
|
processorName: string;
|
|
connectionName: string;
|
|
queues: Record<string, unknown>;
|
|
}>
|
|
>();
|
|
|
|
// Collect all processors and their connections from service map + flow class queues
|
|
const allProcessors = [
|
|
...Object.keys(flowClass.class || {}).map((name) => ({
|
|
name,
|
|
type: "class",
|
|
baseProcessorName: name.replace(/:\{[^}]+\}$/, ""),
|
|
flowClassConnections: flowClass.class[name],
|
|
})),
|
|
...Object.keys(flowClass.flow || {}).map((name) => ({
|
|
name,
|
|
type: "flow",
|
|
baseProcessorName: name.replace(/:\{[^}]+\}$/, ""),
|
|
flowClassConnections: flowClass.flow[name],
|
|
})),
|
|
];
|
|
|
|
allProcessors.forEach(
|
|
({ name, type, baseProcessorName, flowClassConnections }) => {
|
|
const processorInfo = serviceMap.processors[baseProcessorName];
|
|
if (!processorInfo?.connections) return;
|
|
|
|
const processorId = `${type}-${name}`;
|
|
|
|
processorInfo.connections.forEach((connection) => {
|
|
const connectionType = connection.type;
|
|
const connectionKind =
|
|
serviceMap.connection_types[connectionType]?.kind;
|
|
|
|
// Extract queues based on connection kind
|
|
let queues: Record<string, unknown> = {};
|
|
|
|
if (connectionKind === "service") {
|
|
// For service: look for {connection.name}-request and {connection.name}-response for consumers
|
|
// For providers: look for request and response
|
|
if (connection.role === "provides") {
|
|
queues = {
|
|
request: flowClassConnections.request,
|
|
response: flowClassConnections.response,
|
|
};
|
|
} else if (connection.role === "consumes") {
|
|
queues = {
|
|
request: flowClassConnections[`${connection.name}-request`],
|
|
response: flowClassConnections[`${connection.name}-response`],
|
|
};
|
|
}
|
|
} else if (connectionKind === "flow") {
|
|
// For flow: single queue value at connection.name
|
|
queues = { value: flowClassConnections[connection.name] };
|
|
} else if (connectionKind === "passive") {
|
|
// For passive: both consumer and provider use single queue value
|
|
queues = { value: flowClassConnections[connection.name] };
|
|
}
|
|
|
|
// Only add if we found valid queues
|
|
if (Object.values(queues).some((q) => q !== undefined)) {
|
|
if (connection.role === "provides") {
|
|
if (!providersByType.has(connectionType)) {
|
|
providersByType.set(connectionType, []);
|
|
}
|
|
providersByType.get(connectionType)!.push({
|
|
processorId,
|
|
processorName: name,
|
|
connectionName: connection.name,
|
|
queues,
|
|
});
|
|
} else if (connection.role === "consumes") {
|
|
if (!consumersByType.has(connectionType)) {
|
|
consumersByType.set(connectionType, []);
|
|
}
|
|
consumersByType.get(connectionType)!.push({
|
|
processorId,
|
|
processorName: name,
|
|
connectionName: connection.name,
|
|
queues,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
// Create edges by matching providers and consumers using the three algorithms
|
|
consumersByType.forEach((consumers, connectionType) => {
|
|
const providers = providersByType.get(connectionType) || [];
|
|
const connectionKind = serviceMap.connection_types[connectionType]?.kind;
|
|
|
|
if (connectionKind === "passive") {
|
|
// Passive connections - no special handling needed
|
|
}
|
|
|
|
consumers.forEach((consumer) => {
|
|
providers.forEach((provider) => {
|
|
// Skip self-connections
|
|
if (consumer.processorId === provider.processorId) return;
|
|
|
|
let isMatch = false;
|
|
|
|
if (connectionKind === "service") {
|
|
// Service: consumer's {connection-name}-request/response = provider's request/response
|
|
isMatch =
|
|
consumer.queues.request === provider.queues.request &&
|
|
consumer.queues.response === provider.queues.response;
|
|
} else if (connectionKind === "flow") {
|
|
// Flow: same queue value
|
|
isMatch = consumer.queues.value === provider.queues.value;
|
|
} else if (connectionKind === "passive") {
|
|
// Passive: consumer's single queue = provider's single queue
|
|
isMatch = consumer.queues.value === provider.queues.value;
|
|
}
|
|
|
|
if (isMatch) {
|
|
// Determine edge styling
|
|
let edgeColor = "#666666";
|
|
if (connectionKind === "service") edgeColor = "#2563eb";
|
|
else if (connectionKind === "flow") edgeColor = "#16a34a";
|
|
else if (connectionKind === "passive") edgeColor = "#dc2626";
|
|
|
|
// For logical flow direction (consumer requests → provider responds):
|
|
// Use correct logical direction for both animation and layout
|
|
edges.push({
|
|
id: `edge-${edgeIndex++}`,
|
|
source: consumer.processorId, // Logical source (consumer makes request)
|
|
target: provider.processorId, // Logical target (provider receives request)
|
|
sourceHandle: `consume-${consumer.connectionName}`, // Consumer's outgoing handle
|
|
targetHandle: `provide-${provider.connectionName}`, // Provider's incoming handle
|
|
animated: connectionKind === "service",
|
|
style: {
|
|
stroke: edgeColor,
|
|
strokeWidth: connectionKind === "passive" ? 1 : 2,
|
|
},
|
|
label: connectionType,
|
|
type: connectionKind === "passive" ? "step" : "default",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Connect interfaces to their implementing processors
|
|
|
|
Object.entries(flowClass.interfaces || {}).forEach(
|
|
([interfaceName, interfaceQueues]) => {
|
|
const interfaceDefinition = serviceMap.interfaces?.[interfaceName];
|
|
const interfaceKind = interfaceDefinition?.kind;
|
|
|
|
if (!interfaceKind) {
|
|
return;
|
|
}
|
|
|
|
// Find processors that match this interface's queue pattern
|
|
allProcessors.forEach(
|
|
({ name, type, baseProcessorName, flowClassConnections }) => {
|
|
const processorId = `${type}-${name}`;
|
|
const processorInfo = serviceMap.processors[baseProcessorName];
|
|
if (!processorInfo?.connections) return;
|
|
|
|
let isMatch = false;
|
|
let matchingConnection: Connection | null = null;
|
|
|
|
if (interfaceKind === "service") {
|
|
// For service interfaces: check if processor PROVIDES this service
|
|
const interfaceRequest = (
|
|
interfaceQueues as Record<string, unknown>
|
|
).request;
|
|
const interfaceResponse = (
|
|
interfaceQueues as Record<string, unknown>
|
|
).response;
|
|
|
|
// Check if this processor provides this service
|
|
if (
|
|
flowClassConnections.request === interfaceRequest &&
|
|
flowClassConnections.response === interfaceResponse
|
|
) {
|
|
// Find the service connection that provides
|
|
matchingConnection = processorInfo.connections.find(
|
|
(c) => c.role === "provides" && c.name === "service",
|
|
);
|
|
if (matchingConnection) {
|
|
isMatch = true;
|
|
}
|
|
}
|
|
} else if (interfaceKind === "flow") {
|
|
// For flow interfaces: check if processor PROVIDES this flow
|
|
const interfaceQueue = interfaceQueues as string;
|
|
|
|
// Check only provider connections for matching queue
|
|
processorInfo.connections.forEach((connection) => {
|
|
if (connection.role === "provides") {
|
|
const connectionQueue = flowClassConnections[connection.name];
|
|
if (connectionQueue === interfaceQueue) {
|
|
matchingConnection = connection;
|
|
isMatch = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (isMatch && matchingConnection) {
|
|
// Create edge from interface to processor
|
|
const edgeColor =
|
|
interfaceKind === "service" ? "#8b5cf6" : "#ec4899";
|
|
|
|
edges.push({
|
|
id: `interface-edge-${edgeIndex++}`,
|
|
source: `interface-${interfaceName}`,
|
|
target: processorId,
|
|
sourceHandle: `interface-${interfaceName}`,
|
|
targetHandle:
|
|
matchingConnection.role === "provides"
|
|
? `provide-${matchingConnection.name}`
|
|
: `consume-${matchingConnection.name}`,
|
|
animated: true,
|
|
style: {
|
|
stroke: edgeColor,
|
|
strokeWidth: 2,
|
|
strokeDasharray: "5,5",
|
|
},
|
|
label: `implements ${interfaceName}`,
|
|
type: "default",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
return edges;
|
|
};
|
|
|
|
export const FlowClassEditorView: React.FC<FlowClassEditorViewProps> = ({
|
|
flowClassId,
|
|
onBack,
|
|
}) => {
|
|
const { flowClasses } = useFlowClasses();
|
|
const flowClass = flowClasses.find((fc) => fc.id === flowClassId);
|
|
|
|
// Generate nodes and edges from flow class data using useMemo - must be before early return
|
|
const initialNodes = useMemo(() => {
|
|
if (!flowClass) return [];
|
|
const nodes = generateNodesFromFlowClass(flowClass);
|
|
return nodes;
|
|
}, [flowClass]);
|
|
|
|
const generatedEdges = useMemo(() => {
|
|
if (!flowClass) return [];
|
|
const edges = generateEdgesFromFlowClass(flowClass);
|
|
return edges;
|
|
}, [flowClass]);
|
|
|
|
const layoutedNodes = useMemo(() => {
|
|
const layouted = applyDagreLayout(initialNodes, generatedEdges);
|
|
return layouted;
|
|
}, [initialNodes, generatedEdges]);
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
|
// Update nodes and edges when the data changes
|
|
useEffect(() => {
|
|
setNodes(layoutedNodes);
|
|
}, [layoutedNodes, setNodes]);
|
|
|
|
useEffect(() => {
|
|
setEdges(generatedEdges);
|
|
}, [generatedEdges, setEdges]);
|
|
|
|
const onConnect = useCallback(
|
|
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
|
[setEdges],
|
|
);
|
|
|
|
if (!flowClass) {
|
|
return (
|
|
<Box p={6}>
|
|
<HStack spacing={4} mb={4}>
|
|
<Button
|
|
onClick={onBack}
|
|
leftIcon={<ArrowLeft size={16} />}
|
|
variant="ghost"
|
|
>
|
|
Back to Flow Classes
|
|
</Button>
|
|
</HStack>
|
|
<Text>Flow class not found.</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box h="100vh" display="flex" flexDirection="column">
|
|
{/* Header */}
|
|
<VStack
|
|
spacing={4}
|
|
p={6}
|
|
bg="white"
|
|
borderBottom="1px"
|
|
borderColor="gray.200"
|
|
>
|
|
<HStack justifyContent="space-between" w="100%">
|
|
<HStack spacing={4}>
|
|
<Button
|
|
onClick={onBack}
|
|
leftIcon={<ArrowLeft size={16} />}
|
|
variant="ghost"
|
|
>
|
|
Back to Flow Classes
|
|
</Button>
|
|
</HStack>
|
|
<HStack spacing={4}>
|
|
{/*
|
|
<Button leftIcon={<FileCode size={16} />} variant="outline" size="sm">
|
|
Export
|
|
</Button>
|
|
<Button leftIcon={<Construction size={16} />} variant="outline" size="sm">
|
|
Build
|
|
</Button>
|
|
*/}
|
|
</HStack>
|
|
</HStack>
|
|
|
|
<VStack spacing={2} align="start" w="100%">
|
|
<Heading size="lg">{flowClass.name}</Heading>
|
|
</VStack>
|
|
|
|
<Separator />
|
|
</VStack>
|
|
|
|
{/* ReactFlow Canvas */}
|
|
<Box flex={1} position="relative">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
nodeTypes={nodeTypes}
|
|
connectionMode={ConnectionMode.Loose}
|
|
fitView
|
|
attributionPosition="bottom-left"
|
|
>
|
|
<Background />
|
|
<Controls />
|
|
<MiniMap
|
|
nodeColor={(node) => {
|
|
return node.data?.type === "class" ? "#2563eb" : "#16a34a";
|
|
}}
|
|
position="top-right"
|
|
style={{
|
|
backgroundColor: "rgba(255, 255, 255, 0.8)",
|
|
}}
|
|
/>
|
|
</ReactFlow>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|