430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 노드 기반 플로우 에디터 메인 컴포넌트
|
|
*/
|
|
|
|
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
|
|
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
|
import "reactflow/dist/style.css";
|
|
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { NodePalette } from "./sidebar/NodePalette";
|
|
import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar";
|
|
import { Boxes, Settings } from "lucide-react";
|
|
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
|
import { ValidationNotification } from "./ValidationNotification";
|
|
import { FlowToolbar } from "./FlowToolbar";
|
|
import { TableSourceNode } from "./nodes/TableSourceNode";
|
|
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
|
import { ConditionNode } from "./nodes/ConditionNode";
|
|
import { InsertActionNode } from "./nodes/InsertActionNode";
|
|
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
|
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
|
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
|
import { DataTransformNode } from "./nodes/DataTransformNode";
|
|
import { AggregateNode } from "./nodes/AggregateNode";
|
|
import { FormulaTransformNode } from "./nodes/FormulaTransformNode";
|
|
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
|
import { CommentNode } from "./nodes/CommentNode";
|
|
import { LogNode } from "./nodes/LogNode";
|
|
import { EmailActionNode } from "./nodes/EmailActionNode";
|
|
import { ScriptActionNode } from "./nodes/ScriptActionNode";
|
|
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
|
|
import { validateFlow } from "@/lib/utils/flowValidation";
|
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
|
|
|
// 노드 타입들
|
|
const nodeTypes = {
|
|
// 데이터 소스
|
|
tableSource: TableSourceNode,
|
|
externalDBSource: ExternalDBSourceNode,
|
|
restAPISource: RestAPISourceNode,
|
|
// 변환/조건
|
|
condition: ConditionNode,
|
|
dataTransform: DataTransformNode,
|
|
aggregate: AggregateNode,
|
|
formulaTransform: FormulaTransformNode,
|
|
// 데이터 액션
|
|
insertAction: InsertActionNode,
|
|
updateAction: UpdateActionNode,
|
|
deleteAction: DeleteActionNode,
|
|
upsertAction: UpsertActionNode,
|
|
// 외부 연동 액션
|
|
emailAction: EmailActionNode,
|
|
scriptAction: ScriptActionNode,
|
|
httpRequestAction: HttpRequestActionNode,
|
|
// 유틸리티
|
|
comment: CommentNode,
|
|
log: LogNode,
|
|
};
|
|
|
|
/**
|
|
* FlowEditor 내부 컴포넌트
|
|
*/
|
|
interface FlowEditorInnerProps {
|
|
initialFlowId?: number | null;
|
|
}
|
|
|
|
// 플로우 에디터 툴바 버튼 설정
|
|
const flowToolbarButtons: ToolbarButton[] = [
|
|
{
|
|
id: "nodes",
|
|
label: "노드",
|
|
icon: <Boxes className="h-5 w-5" />,
|
|
shortcut: "N",
|
|
group: "source",
|
|
panelWidth: 300,
|
|
},
|
|
{
|
|
id: "properties",
|
|
label: "속성",
|
|
icon: <Settings className="h-5 w-5" />,
|
|
shortcut: "P",
|
|
group: "editor",
|
|
panelWidth: 350,
|
|
},
|
|
];
|
|
|
|
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
|
const { screenToFlowPosition, setCenter } = useReactFlow();
|
|
|
|
// 패널 표시 상태
|
|
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
|
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
|
|
|
const {
|
|
nodes,
|
|
edges,
|
|
onNodesChange,
|
|
onEdgesChange,
|
|
onConnect,
|
|
onNodeDragStart,
|
|
addNode,
|
|
selectNodes,
|
|
selectedNodes,
|
|
removeNodes,
|
|
undo,
|
|
redo,
|
|
loadFlow,
|
|
} = useFlowEditorStore();
|
|
|
|
// 🆕 실시간 플로우 검증
|
|
const validations = useMemo<FlowValidation[]>(() => {
|
|
return validateFlow(nodes, edges);
|
|
}, [nodes, edges]);
|
|
|
|
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
|
const handleValidationNodeClick = useCallback(
|
|
(nodeId: string) => {
|
|
const node = nodes.find((n) => n.id === nodeId);
|
|
if (node) {
|
|
selectNodes([nodeId]);
|
|
setCenter(node.position.x + 125, node.position.y + 50, {
|
|
zoom: 1.5,
|
|
duration: 500,
|
|
});
|
|
}
|
|
},
|
|
[nodes, selectNodes, setCenter],
|
|
);
|
|
|
|
// 속성 패널 상태 동기화
|
|
useEffect(() => {
|
|
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
|
setShowPropertiesPanelLocal(true);
|
|
}
|
|
}, [selectedNodes, showPropertiesPanelLocal]);
|
|
|
|
// 초기 플로우 로드
|
|
useEffect(() => {
|
|
const fetchAndLoadFlow = async () => {
|
|
if (initialFlowId) {
|
|
try {
|
|
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
const flow = response.data.data;
|
|
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
|
|
|
loadFlow(
|
|
flow.flowId,
|
|
flow.flowName,
|
|
flow.flowDescription || "",
|
|
flowData.nodes || [],
|
|
flowData.edges || [],
|
|
);
|
|
|
|
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
|
|
if (flowData.nodes && flowData.nodes.length > 0) {
|
|
const firstNode = flowData.nodes[0];
|
|
selectNodes([firstNode.id]);
|
|
setShowPropertiesPanelLocal(true);
|
|
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("플로우 로드 실패:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchAndLoadFlow();
|
|
}, [initialFlowId, loadFlow, selectNodes]);
|
|
|
|
/**
|
|
* 노드 선택 변경 핸들러
|
|
*/
|
|
const onSelectionChange = useCallback(
|
|
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
|
const selectedIds = selectedNodes.map((node) => node.id);
|
|
selectNodes(selectedIds);
|
|
console.log("🔍 선택된 노드:", selectedIds);
|
|
},
|
|
[selectNodes],
|
|
);
|
|
|
|
/**
|
|
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
|
*/
|
|
const onKeyDown = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
console.log("⏪ Undo");
|
|
undo();
|
|
return;
|
|
}
|
|
|
|
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
|
if (
|
|
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
|
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
|
) {
|
|
event.preventDefault();
|
|
console.log("⏩ Redo");
|
|
redo();
|
|
return;
|
|
}
|
|
|
|
// Delete: Delete/Backspace 키로 노드 삭제
|
|
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
|
event.preventDefault();
|
|
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
|
removeNodes(selectedNodes);
|
|
}
|
|
},
|
|
[selectedNodes, removeNodes, undo, redo],
|
|
);
|
|
|
|
/**
|
|
* 드래그 앤 드롭 핸들러
|
|
*/
|
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
}, []);
|
|
|
|
const onDrop = useCallback(
|
|
(event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
|
|
const type = event.dataTransfer.getData("application/reactflow");
|
|
if (!type) return;
|
|
|
|
const position = screenToFlowPosition({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
});
|
|
|
|
// 🔥 노드 타입별 기본 데이터 설정
|
|
const defaultData: any = {
|
|
displayName: `새 ${type} 노드`,
|
|
};
|
|
|
|
// REST API 소스 노드의 경우
|
|
if (type === "restAPISource") {
|
|
defaultData.method = "GET";
|
|
defaultData.url = "";
|
|
defaultData.headers = {};
|
|
defaultData.timeout = 30000;
|
|
defaultData.responseFields = []; // 빈 배열로 초기화
|
|
defaultData.responseMapping = "";
|
|
}
|
|
|
|
// 데이터 액션 노드의 경우 targetType 기본값 설정
|
|
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
|
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
|
defaultData.fieldMappings = [];
|
|
defaultData.options = {};
|
|
|
|
if (type === "updateAction" || type === "deleteAction") {
|
|
defaultData.whereConditions = [];
|
|
}
|
|
|
|
if (type === "upsertAction") {
|
|
defaultData.conflictKeys = [];
|
|
}
|
|
}
|
|
|
|
// 메일 발송 노드
|
|
if (type === "emailAction") {
|
|
defaultData.displayName = "메일 발송";
|
|
defaultData.smtpConfig = {
|
|
host: "",
|
|
port: 587,
|
|
secure: false,
|
|
};
|
|
defaultData.from = "";
|
|
defaultData.to = "";
|
|
defaultData.subject = "";
|
|
defaultData.body = "";
|
|
defaultData.bodyType = "text";
|
|
}
|
|
|
|
// 스크립트 실행 노드
|
|
if (type === "scriptAction") {
|
|
defaultData.displayName = "스크립트 실행";
|
|
defaultData.scriptType = "python";
|
|
defaultData.executionMode = "inline";
|
|
defaultData.inlineScript = "";
|
|
defaultData.inputMethod = "stdin";
|
|
defaultData.inputFormat = "json";
|
|
defaultData.outputHandling = {
|
|
captureStdout: true,
|
|
captureStderr: true,
|
|
parseOutput: "text",
|
|
};
|
|
}
|
|
|
|
// HTTP 요청 노드
|
|
if (type === "httpRequestAction") {
|
|
defaultData.displayName = "HTTP 요청";
|
|
defaultData.url = "";
|
|
defaultData.method = "GET";
|
|
defaultData.bodyType = "none";
|
|
defaultData.authentication = { type: "none" };
|
|
defaultData.options = {
|
|
timeout: 30000,
|
|
followRedirects: true,
|
|
};
|
|
}
|
|
|
|
const newNode: any = {
|
|
id: `node_${Date.now()}`,
|
|
type,
|
|
position,
|
|
data: defaultData,
|
|
};
|
|
|
|
addNode(newNode);
|
|
},
|
|
[screenToFlowPosition, addNode],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
|
{/* 좌측 통합 툴바 */}
|
|
<LeftUnifiedToolbar
|
|
buttons={flowToolbarButtons}
|
|
panelStates={{
|
|
nodes: { isOpen: showNodesPanel },
|
|
properties: { isOpen: showPropertiesPanelLocal },
|
|
}}
|
|
onTogglePanel={(panelId) => {
|
|
if (panelId === "nodes") {
|
|
setShowNodesPanel(!showNodesPanel);
|
|
} else if (panelId === "properties") {
|
|
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* 노드 라이브러리 패널 */}
|
|
{showNodesPanel && (
|
|
<div className="h-full w-[300px] border-r bg-white">
|
|
<NodePalette />
|
|
</div>
|
|
)}
|
|
|
|
{/* 중앙 캔버스 */}
|
|
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
|
<ReactFlow
|
|
nodes={nodes as any}
|
|
edges={edges as any}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onNodeDragStart={onNodeDragStart}
|
|
onSelectionChange={onSelectionChange}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
className="bg-gray-50"
|
|
deleteKeyCode={["Delete", "Backspace"]}
|
|
>
|
|
{/* 배경 그리드 */}
|
|
<Background gap={16} size={1} color="#E5E7EB" />
|
|
|
|
{/* 컨트롤 버튼 */}
|
|
<Controls className="bg-white shadow-md" />
|
|
|
|
{/* 미니맵 */}
|
|
<MiniMap
|
|
className="bg-white shadow-md"
|
|
nodeColor={(node) => {
|
|
// 노드 타입별 색상 (추후 구현)
|
|
return "#3B82F6";
|
|
}}
|
|
maskColor="rgba(0, 0, 0, 0.1)"
|
|
/>
|
|
|
|
{/* 상단 툴바 */}
|
|
<Panel position="top-center" className="pointer-events-auto">
|
|
<FlowToolbar validations={validations} />
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
|
|
{/* 우측 속성 패널 */}
|
|
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: "350px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
className="border-l bg-white"
|
|
>
|
|
<PropertiesPanel />
|
|
</div>
|
|
)}
|
|
|
|
{/* 검증 알림 (우측 상단 플로팅) */}
|
|
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
|
*/
|
|
interface FlowEditorProps {
|
|
initialFlowId?: number | null;
|
|
}
|
|
|
|
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
|
|
return (
|
|
<div className="h-full w-full">
|
|
<ReactFlowProvider>
|
|
<FlowEditorInner initialFlowId={initialFlowId} />
|
|
</ReactFlowProvider>
|
|
</div>
|
|
);
|
|
}
|