ERP-node/frontend/components/dataflow/node-editor/FlowEditor.tsx

559 lines
16 KiB
TypeScript

"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<string, any> {
const paletteItem = getNodePaletteItem(type);
const base: Record<string, any> = {
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<HTMLDivElement>(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<FlowValidation[]>(
() => 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: <Pencil className="h-3.5 w-3.5" />,
onClick: () => {
selectNodes([nodeId]);
setSlideOverOpen(true);
},
},
{
label: "복제",
icon: <Copy className="h-3.5 w-3.5" />,
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: <Trash2 className="h-3.5 w-3.5" />,
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 (
<div
className="relative flex h-full w-full"
style={{ height: "100%", overflow: "hidden" }}
>
{/* 100% 캔버스 */}
<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}
onNodeDoubleClick={onNodeDoubleClick}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
onPaneClick={() => setContextMenu(null)}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
fitView
className="bg-zinc-950"
deleteKeyCode={["Delete", "Backspace"]}
>
<Background gap={20} size={1} color="#27272a" />
<Controls
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg [&>button]:!border-zinc-700 [&>button]:!bg-zinc-900 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-800 [&>button:hover]:!text-zinc-200"
showInteractive={false}
/>
<MiniMap
className="!rounded-lg !border-zinc-700 !bg-zinc-900 !shadow-lg"
nodeColor={(node) => {
const item = getNodePaletteItem(node.type || "");
return item?.color || "#6B7280";
}}
maskColor="rgba(0, 0, 0, 0.6)"
/>
{/* Breadcrumb (좌상단) */}
<Panel position="top-left" className="pointer-events-auto">
<div className="rounded-lg border border-zinc-700/60 bg-zinc-900/90 px-3 py-2 backdrop-blur-sm">
<FlowBreadcrumb />
</div>
</Panel>
{/* 플로팅 툴바 (상단 중앙) */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar
validations={validations}
onSaveComplete={onSaveComplete}
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
/>
</Panel>
</ReactFlow>
</div>
{/* Slide-over 속성 패널 */}
<SlideOverSheet
isOpen={slideOverOpen && selectedNodes.length > 0}
onClose={() => setSlideOverOpen(false)}
/>
{/* Command Palette */}
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onSelectNode={handleCommandSelect}
/>
{/* 노드 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<NodeContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.nodeId)}
onClose={() => setContextMenu(null)}
/>
)}
{/* 검증 알림 */}
<ValidationNotification
validations={validations}
onNodeClick={handleValidationNodeClick}
/>
{/* 빈 캔버스 힌트 */}
{nodes.length === 0 && !commandPaletteOpen && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="mb-2 text-sm text-zinc-500">
</p>
<p className="text-xs text-zinc-600">
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[11px]">
/
</kbd>{" "}
</p>
</div>
</div>
)}
</div>
);
}
interface FlowEditorProps {
initialFlowId?: number | null;
onSaveComplete?: (flowId: number, flowName: string) => void;
embedded?: boolean;
}
export function FlowEditor({
initialFlowId,
onSaveComplete,
embedded = false,
}: FlowEditorProps = {}) {
return (
<div className="h-full w-full">
<ReactFlowProvider>
<FlowEditorInner
initialFlowId={initialFlowId}
onSaveComplete={onSaveComplete}
embedded={embedded}
/>
</ReactFlowProvider>
</div>
);
}