"use client"; /** * 노드 기반 플로우 에디터 메인 컴포넌트 * - 100% 캔버스 + Command Palette (/ 키) + Slide-over 속성 패널 */ 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 { CommandPalette } from "./CommandPalette"; import { SlideOverSheet } from "./SlideOverSheet"; import { FlowBreadcrumb } from "./FlowBreadcrumb"; import { NodeContextMenu } from "./NodeContextMenu"; import { ValidationNotification } from "./ValidationNotification"; import { FlowToolbar } from "./FlowToolbar"; import { getNodePaletteItem } from "./sidebar/nodePaletteConfig"; import { Pencil, Copy, Trash2 } from "lucide-react"; import { toast } from "sonner"; 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 { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode"; 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, procedureCallAction: ProcedureCallActionNode, comment: CommentNode, log: LogNode, }; interface FlowEditorInnerProps { initialFlowId?: number | null; onSaveComplete?: (flowId: number, flowName: string) => void; embedded?: boolean; } function getDefaultNodeData(type: string): Record { const paletteItem = getNodePaletteItem(type); const base: Record = { displayName: paletteItem?.label || `새 ${type} 노드`, }; if (type === "restAPISource") { Object.assign(base, { method: "GET", url: "", headers: {}, timeout: 30000, responseFields: [], responseMapping: "", }); } if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { Object.assign(base, { targetType: "internal", fieldMappings: [], options: {}, }); if (type === "updateAction" || type === "deleteAction") { base.whereConditions = []; } if (type === "upsertAction") { base.conflictKeys = []; } } if (type === "emailAction") { Object.assign(base, { displayName: "메일 발송", smtpConfig: { host: "", port: 587, secure: false }, from: "", to: "", subject: "", body: "", bodyType: "text", }); } if (type === "scriptAction") { Object.assign(base, { displayName: "스크립트 실행", scriptType: "python", executionMode: "inline", inlineScript: "", inputMethod: "stdin", inputFormat: "json", outputHandling: { captureStdout: true, captureStderr: true, parseOutput: "text" }, }); } if (type === "httpRequestAction") { Object.assign(base, { displayName: "HTTP 요청", url: "", method: "GET", bodyType: "none", authentication: { type: "none" }, options: { timeout: 30000, followRedirects: true }, }); } return base; } function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false, }: FlowEditorInnerProps) { const reactFlowWrapper = useRef(null); const { screenToFlowPosition, setCenter, getViewport } = useReactFlow(); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [slideOverOpen, setSlideOverOpen] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; nodeId: string; } | null>(null); const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onNodeDragStart, addNode, selectNodes, selectedNodes, removeNodes, undo, redo, loadFlow, } = useFlowEditorStore(); const validations = useMemo( () => 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) { setSlideOverOpen(true); } }, [selectedNodes]); // 플로우 로드 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 || [], ); } } catch (error) { console.error("플로우 로드 실패:", error); } } }; fetchAndLoadFlow(); }, [initialFlowId, loadFlow]); const onSelectionChange = useCallback( ({ nodes: selected }: { nodes: any[] }) => { const selectedIds = selected.map((n) => n.id); selectNodes(selectedIds); }, [selectNodes], ); // 더블클릭으로 속성 패널 열기 const onNodeDoubleClick = useCallback( (_event: React.MouseEvent, node: any) => { selectNodes([node.id]); setSlideOverOpen(true); }, [selectNodes], ); // 우클릭 컨텍스트 메뉴 const onNodeContextMenu = useCallback( (event: React.MouseEvent, node: any) => { event.preventDefault(); selectNodes([node.id]); setContextMenu({ x: event.clientX, y: event.clientY, nodeId: node.id, }); }, [selectNodes], ); // 캔버스 우클릭 → 커맨드 팔레트 const onPaneContextMenu = useCallback( (event: React.MouseEvent | MouseEvent) => { event.preventDefault(); setCommandPaletteOpen(true); }, [], ); // 컨텍스트 메뉴 아이템 생성 const getContextMenuItems = useCallback( (nodeId: string) => { const node = nodes.find((n) => n.id === nodeId); const nodeName = (node?.data as any)?.displayName || "노드"; return [ { label: "속성 편집", icon: , onClick: () => { selectNodes([nodeId]); setSlideOverOpen(true); }, }, { label: "복제", icon: , onClick: () => { if (!node) return; const newNode: any = { id: `node_${Date.now()}`, type: node.type, position: { x: node.position.x + 40, y: node.position.y + 40, }, data: { ...(node.data as any) }, }; addNode(newNode); selectNodes([newNode.id]); toast.success(`"${nodeName}" 노드를 복제했어요`); }, }, { label: "삭제", icon: , onClick: () => { removeNodes([nodeId]); toast.success(`"${nodeName}" 노드를 삭제했어요`); }, danger: true, }, ]; }, [nodes, selectNodes, addNode, removeNodes], ); // "/" 키로 커맨드 팔레트 열기, Esc로 속성 패널 닫기 등 const onKeyDown = useCallback( (event: React.KeyboardEvent) => { const target = event.target as HTMLElement; const isInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable; if (!isInput && event.key === "/" && !event.ctrlKey && !event.metaKey) { event.preventDefault(); setCommandPaletteOpen(true); return; } if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) { event.preventDefault(); undo(); return; } if ( ((event.ctrlKey || event.metaKey) && event.key === "y") || ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z") ) { event.preventDefault(); redo(); return; } if ( (event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0 && !isInput ) { event.preventDefault(); removeNodes(selectedNodes); } }, [selectedNodes, removeNodes, undo, redo], ); // 커맨드 팔레트에서 노드 선택 시 뷰포트 중앙에 배치 const handleCommandSelect = useCallback( (nodeType: string) => { const viewport = getViewport(); const wrapper = reactFlowWrapper.current; if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const centerX = rect.width / 2; const centerY = rect.height / 2; const position = screenToFlowPosition({ x: rect.left + centerX, y: rect.top + centerY, }); const newNode: any = { id: `node_${Date.now()}`, type: nodeType, position, data: getDefaultNodeData(nodeType), }; addNode(newNode); selectNodes([newNode.id]); }, [screenToFlowPosition, addNode, selectNodes, getViewport], ); // 기존 드래그 앤 드롭 (하위 호환) 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 newNode: any = { id: `node_${Date.now()}`, type, position, data: getDefaultNodeData(type), }; addNode(newNode); }, [screenToFlowPosition, addNode], ); return (
{/* 100% 캔버스 */}
setContextMenu(null)} onDragOver={onDragOver} onDrop={onDrop} nodeTypes={nodeTypes} fitView className="bg-zinc-950" deleteKeyCode={["Delete", "Backspace"]} > { const item = getNodePaletteItem(node.type || ""); return item?.color || "#6B7280"; }} maskColor="rgba(0, 0, 0, 0.6)" /> {/* Breadcrumb (좌상단) */}
{/* 플로팅 툴바 (상단 중앙) */} setCommandPaletteOpen(true)} />
{/* Slide-over 속성 패널 */} 0} onClose={() => setSlideOverOpen(false)} /> {/* Command Palette */} setCommandPaletteOpen(false)} onSelectNode={handleCommandSelect} /> {/* 노드 우클릭 컨텍스트 메뉴 */} {contextMenu && ( setContextMenu(null)} /> )} {/* 검증 알림 */} {/* 빈 캔버스 힌트 */} {nodes.length === 0 && !commandPaletteOpen && (

캔버스가 비어 있어요

/ {" "} 키를 눌러서 노드를 추가해 보세요

)}
); } interface FlowEditorProps { initialFlowId?: number | null; onSaveComplete?: (flowId: number, flowName: string) => void; embedded?: boolean; } export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false, }: FlowEditorProps = {}) { return (
); }