ERP-node/frontend/lib/stores/flowEditorStore.ts

793 lines
23 KiB
TypeScript

/**
* 노드 에디터 상태 관리 스토어
*/
import { create } from "zustand";
import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "reactflow";
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
// 🔥 외부 커넥션 캐시 타입
interface ExternalConnectionCache {
data: any[];
timestamp: number;
}
interface FlowEditorState {
// 노드 및 엣지
nodes: FlowNode[];
edges: FlowEdge[];
// 선택 상태
selectedNodes: string[];
selectedEdges: string[];
// 플로우 메타데이터
flowId: number | null;
flowName: string;
flowDescription: string;
// UI 상태
isExecuting: boolean;
isSaving: boolean;
showValidationPanel: boolean;
showPropertiesPanel: boolean;
// 검증 결과
validationResult: ValidationResult | null;
// 🔥 외부 커넥션 캐시 (전역 캐싱)
externalConnectionsCache: ExternalConnectionCache | null;
// ========================================================================
// 노드 관리
// ========================================================================
setNodes: (nodes: FlowNode[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
addNode: (node: FlowNode) => void;
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
removeNode: (id: string) => void;
removeNodes: (ids: string[]) => void;
// ========================================================================
// 🔥 외부 커넥션 캐시 관리
// ========================================================================
setExternalConnectionsCache: (data: any[]) => void;
clearExternalConnectionsCache: () => void;
getExternalConnectionsCache: () => any[] | null;
// ========================================================================
// 엣지 관리
// ========================================================================
setEdges: (edges: FlowEdge[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
removeEdge: (id: string) => void;
removeEdges: (ids: string[]) => void;
// ========================================================================
// 선택 관리
// ========================================================================
selectNode: (id: string, multi?: boolean) => void;
selectNodes: (ids: string[]) => void;
selectEdge: (id: string, multi?: boolean) => void;
clearSelection: () => void;
// ========================================================================
// 플로우 관리
// ========================================================================
loadFlow: (id: number, name: string, description: string, nodes: FlowNode[], edges: FlowEdge[]) => void;
clearFlow: () => void;
setFlowName: (name: string) => void;
setFlowDescription: (description: string) => void;
saveFlow: () => Promise<{ success: boolean; flowId?: number; message?: string }>;
exportFlow: () => string;
// ========================================================================
// 검증
// ========================================================================
validateFlow: () => ValidationResult;
setValidationResult: (result: ValidationResult | null) => void;
// ========================================================================
// UI 상태
// ========================================================================
setIsExecuting: (value: boolean) => void;
setIsSaving: (value: boolean) => void;
setShowValidationPanel: (value: boolean) => void;
setShowPropertiesPanel: (value: boolean) => void;
// ========================================================================
// 유틸리티
// ========================================================================
getNodeById: (id: string) => FlowNode | undefined;
getEdgeById: (id: string) => FlowEdge | undefined;
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
}
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
// 초기 상태
nodes: [],
edges: [],
selectedNodes: [],
selectedEdges: [],
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
isExecuting: false,
isSaving: false,
showValidationPanel: false,
showPropertiesPanel: true,
validationResult: null,
externalConnectionsCache: null, // 🔥 캐시 초기화
// ========================================================================
// 노드 관리
// ========================================================================
setNodes: (nodes) => set({ nodes }),
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
});
},
addNode: (node) => {
set((state) => ({
nodes: [...state.nodes, node],
}));
},
updateNode: (id, data) => {
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? {
...node,
data: { ...node.data, ...data },
}
: node,
),
}));
},
removeNode: (id) => {
set((state) => ({
nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
}));
},
removeNodes: (ids) => {
set((state) => ({
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
}));
},
// ========================================================================
// 엣지 관리
// ========================================================================
setEdges: (edges) => set({ edges }),
onEdgesChange: (changes) => {
set({
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
});
},
onConnect: (connection) => {
// 연결 검증
const validation = validateConnection(connection, get().nodes);
if (!validation.valid) {
console.warn("연결 검증 실패:", validation.error);
return;
}
set((state) => ({
edges: addEdge(
{
...connection,
type: "smoothstep",
animated: false,
data: {
validation: { valid: true },
},
},
state.edges,
) as FlowEdge[],
}));
},
removeEdge: (id) => {
set((state) => ({
edges: state.edges.filter((edge) => edge.id !== id),
}));
},
removeEdges: (ids) => {
set((state) => ({
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
}));
},
// ========================================================================
// 선택 관리
// ========================================================================
selectNode: (id, multi = false) => {
set((state) => ({
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
}));
},
selectNodes: (ids) => {
set({
selectedNodes: ids,
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
});
},
selectEdge: (id, multi = false) => {
set((state) => ({
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
}));
},
clearSelection: () => {
set({ selectedNodes: [], selectedEdges: [] });
},
// ========================================================================
// 플로우 관리
// ========================================================================
loadFlow: (id, name, description, nodes, edges) => {
set({
flowId: id,
flowName: name,
flowDescription: description,
nodes,
edges,
selectedNodes: [],
selectedEdges: [],
});
},
clearFlow: () => {
set({
flowId: null,
flowName: "새 제어 플로우",
flowDescription: "",
nodes: [],
edges: [],
selectedNodes: [],
selectedEdges: [],
validationResult: null,
});
},
setFlowName: (name) => set({ flowName: name }),
setFlowDescription: (description) => set({ flowDescription: description }),
saveFlow: async () => {
const { flowId, flowName, flowDescription, nodes, edges } = get();
if (!flowName || flowName.trim() === "") {
return { success: false, message: "플로우 이름을 입력해주세요." };
}
// 검증
const validation = get().validateFlow();
if (!validation.valid) {
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
}
set({ isSaving: true });
try {
// 플로우 데이터 직렬화
const flowData = {
nodes: nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
})),
};
const result = flowId
? await updateNodeFlow({
flowId,
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
})
: await createNodeFlow({
flowName,
flowDescription,
flowData: JSON.stringify(flowData),
});
set({ flowId: result.flowId });
return { success: true, flowId: result.flowId, message: "저장 완료!" };
} catch (error) {
console.error("플로우 저장 오류:", error);
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
} finally {
set({ isSaving: false });
}
},
exportFlow: () => {
const { flowName, flowDescription, nodes, edges } = get();
const flowData = {
flowName,
flowDescription,
nodes,
edges,
version: "1.0",
exportedAt: new Date().toISOString(),
};
return JSON.stringify(flowData, null, 2);
},
// ========================================================================
// 검증
// ========================================================================
validateFlow: () => {
const { nodes, edges } = get();
const result = performFlowValidation(nodes, edges);
set({ validationResult: result });
return result;
},
setValidationResult: (result) => set({ validationResult: result }),
// ========================================================================
// UI 상태
// ========================================================================
setIsExecuting: (value) => set({ isExecuting: value }),
setIsSaving: (value) => set({ isSaving: value }),
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
// ========================================================================
// 유틸리티
// ========================================================================
getNodeById: (id) => {
return get().nodes.find((node) => node.id === id);
},
getEdgeById: (id) => {
return get().edges.find((edge) => edge.id === id);
},
getConnectedNodes: (nodeId) => {
const { nodes, edges } = get();
const incoming = edges
.filter((edge) => edge.target === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.source))
.filter((node): node is FlowNode => node !== undefined);
const outgoing = edges
.filter((edge) => edge.source === nodeId)
.map((edge) => nodes.find((node) => node.id === edge.target))
.filter((node): node is FlowNode => node !== undefined);
return { incoming, outgoing };
},
// ========================================================================
// 🔥 외부 커넥션 캐시 관리
// ========================================================================
setExternalConnectionsCache: (data) => {
set({
externalConnectionsCache: {
data,
timestamp: Date.now(),
},
});
},
clearExternalConnectionsCache: () => {
set({ externalConnectionsCache: null });
},
getExternalConnectionsCache: () => {
const cache = get().externalConnectionsCache;
if (!cache) return null;
// 🔥 5분 후 캐시 만료
const CACHE_DURATION = 5 * 60 * 1000; // 5분
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
if (isExpired) {
set({ externalConnectionsCache: null });
return null;
}
return cache.data;
},
}));
// ============================================================================
// 헬퍼 함수들
// ============================================================================
/**
* 연결 검증
*/
function validateConnection(connection: Connection, nodes: FlowNode[]): { valid: boolean; error?: string } {
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) {
return { valid: false, error: "노드를 찾을 수 없습니다" };
}
// 소스 노드가 출력을 가져야 함
if (isSourceOnlyNode(sourceNode.type)) {
// OK
} else if (isActionNode(targetNode.type)) {
// 액션 노드는 입력만 받을 수 있음
} else {
// 기타 경우는 허용
}
// 자기 자신에게 연결 방지
if (connection.source === connection.target) {
return { valid: false, error: "자기 자신에게 연결할 수 없습니다" };
}
return { valid: true };
}
/**
* 플로우 검증
*/
function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult {
const errors: ValidationResult["errors"] = [];
// 1. 노드가 하나도 없으면 경고
if (nodes.length === 0) {
errors.push({
message: "노드가 없습니다. 최소 하나의 노드를 추가하세요.",
severity: "warning",
});
}
// 2. 소스 노드 확인
const sourceNodes = nodes.filter((n) => isSourceNode(n.type));
if (sourceNodes.length === 0 && nodes.length > 0) {
errors.push({
message: "데이터 소스 노드가 필요합니다.",
severity: "error",
});
}
// 3. 액션 노드 확인
const actionNodes = nodes.filter((n) => isActionNode(n.type));
if (actionNodes.length === 0 && nodes.length > 0) {
errors.push({
message: "최소 하나의 액션 노드가 필요합니다.",
severity: "error",
});
}
// 4. 고아 노드 확인 (연결되지 않은 노드) - Comment와 Log는 제외
nodes.forEach((node) => {
// Comment와 Log는 독립적으로 존재 가능
if (node.type === "comment" || node.type === "log") {
return;
}
const hasIncoming = edges.some((e) => e.target === node.id);
const hasOutgoing = edges.some((e) => e.source === node.id);
if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) {
errors.push({
nodeId: node.id,
message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`,
severity: "warning",
});
}
});
// 5. 액션 노드가 입력을 받는지 확인
actionNodes.forEach((node) => {
const hasInput = edges.some((e) => e.target === node.id);
if (!hasInput) {
errors.push({
nodeId: node.id,
message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`,
severity: "error",
});
}
});
// 6. 순환 참조 검증
const cycles = detectCycles(nodes, edges);
cycles.forEach((cycle) => {
errors.push({
message: `순환 참조가 감지되었습니다: ${cycle.join(" → ")}`,
severity: "error",
});
});
// 7. 노드별 필수 속성 검증
nodes.forEach((node) => {
const nodeErrors = validateNodeProperties(node);
errors.push(...nodeErrors);
});
return {
valid: errors.filter((e) => e.severity === "error").length === 0,
errors,
};
}
/**
* 소스 노드 여부 확인
*/
function isSourceNode(type: NodeType): boolean {
return type === "tableSource" || type === "externalDBSource" || type === "restAPISource";
}
/**
* 소스 전용 노드 여부 확인
*/
function isSourceOnlyNode(type: NodeType): boolean {
return isSourceNode(type);
}
/**
* 액션 노드 여부 확인
*/
function isActionNode(type: NodeType): boolean {
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
}
/**
* 순환 참조 검증 (DFS 사용)
*/
function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
// 인접 리스트 생성
const adjacencyList = new Map<string, string[]>();
nodes.forEach((node) => adjacencyList.set(node.id, []));
edges.forEach((edge) => {
const targets = adjacencyList.get(edge.source) || [];
targets.push(edge.target);
adjacencyList.set(edge.source, targets);
});
function dfs(nodeId: string, path: string[]): boolean {
visited.add(nodeId);
recursionStack.add(nodeId);
path.push(nodeId);
const neighbors = adjacencyList.get(nodeId) || [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (dfs(neighbor, [...path])) {
return true;
}
} else if (recursionStack.has(neighbor)) {
// 순환 발견
const cycleStart = path.indexOf(neighbor);
const cycle = path.slice(cycleStart).concat(neighbor);
const nodeNames = cycle.map((id) => {
const node = nodes.find((n) => n.id === id);
return node?.data.displayName || id;
});
cycles.push(nodeNames);
return true;
}
}
recursionStack.delete(nodeId);
return false;
}
// 모든 노드에서 DFS 시작
nodes.forEach((node) => {
if (!visited.has(node.id)) {
dfs(node.id, []);
}
});
return cycles;
}
/**
* 노드별 필수 속성 검증
*/
function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
const errors: ValidationResult["errors"] = [];
switch (node.type) {
case "tableSource":
if (!node.data.tableName || node.data.tableName.trim() === "") {
errors.push({
nodeId: node.id,
message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
severity: "error",
});
}
break;
case "externalDBSource":
if (!node.data.connectionName || node.data.connectionName.trim() === "") {
errors.push({
nodeId: node.id,
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`,
severity: "error",
});
}
if (!node.data.tableName || node.data.tableName.trim() === "") {
errors.push({
nodeId: node.id,
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
severity: "error",
});
}
break;
case "restAPISource":
if (!node.data.url || node.data.url.trim() === "") {
errors.push({
nodeId: node.id,
message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`,
severity: "error",
});
}
if (!node.data.method) {
errors.push({
nodeId: node.id,
message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
severity: "error",
});
}
break;
case "insertAction":
case "updateAction":
case "deleteAction":
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
severity: "error",
});
}
if (node.type === "insertAction" || node.type === "updateAction") {
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
severity: "error",
});
}
}
if (node.type === "updateAction" || node.type === "deleteAction") {
if (!node.data.whereConditions || node.data.whereConditions.length === 0) {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`,
severity: "error",
});
}
}
break;
case "upsertAction":
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
severity: "error",
});
}
if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
severity: "error",
});
}
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
severity: "error",
});
}
break;
case "condition":
if (!node.data.conditions || node.data.conditions.length === 0) {
errors.push({
nodeId: node.id,
message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
severity: "error",
});
}
break;
case "fieldMapping":
if (!node.data.mappings || node.data.mappings.length === 0) {
errors.push({
nodeId: node.id,
message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`,
severity: "warning",
});
}
break;
case "dataTransform":
if (!node.data.transformations || node.data.transformations.length === 0) {
errors.push({
nodeId: node.id,
message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
severity: "warning",
});
}
break;
case "log":
if (!node.data.message || node.data.message.trim() === "") {
errors.push({
nodeId: node.id,
message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`,
severity: "warning",
});
}
break;
case "comment":
// Comment 노드는 내용이 없어도 괜찮음
break;
}
return errors;
}
/**
* 액션 타입 이름 반환
*/
function getActionTypeName(type: string): string {
const names: Record<string, string> = {
insertAction: "INSERT",
updateAction: "UPDATE",
deleteAction: "DELETE",
upsertAction: "UPSERT",
};
return names[type] || type;
}