플로우 페이지네이션 안보임

This commit is contained in:
kjs
2025-10-24 15:40:08 +09:00
parent 0a57a2cef1
commit 7d6281d289
21 changed files with 2101 additions and 469 deletions

View File

@@ -4,7 +4,7 @@
* 노드 기반 플로우 에디터 메인 컴포넌트
*/
import { useCallback, useRef, useEffect, useState } from "react";
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
import "reactflow/dist/style.css";
@@ -14,6 +14,7 @@ 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";
@@ -27,6 +28,8 @@ import { DataTransformNode } from "./nodes/DataTransformNode";
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
import { CommentNode } from "./nodes/CommentNode";
import { LogNode } from "./nodes/LogNode";
import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
// 노드 타입들
const nodeTypes = {
@@ -77,7 +80,7 @@ const flowToolbarButtons: ToolbarButton[] = [
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
const { screenToFlowPosition, setCenter } = useReactFlow();
// 패널 표시 상태
const [showNodesPanel, setShowNodesPanel] = useState(true);
@@ -91,8 +94,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
onConnect,
onNodeDragStart,
addNode,
showPropertiesPanel,
setShowPropertiesPanel,
selectNodes,
selectedNodes,
removeNodes,
@@ -101,6 +102,26 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
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) {
@@ -245,7 +266,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
);
return (
<div className="flex h-full w-full">
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
{/* 좌측 통합 툴바 */}
<LeftUnifiedToolbar
buttons={flowToolbarButtons}
@@ -258,7 +279,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
setShowNodesPanel(!showNodesPanel);
} else if (panelId === "properties") {
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
setShowPropertiesPanel(!showPropertiesPanelLocal);
}
}}
/>
@@ -273,8 +293,8 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
{/* 중앙 캔버스 */}
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
<ReactFlow
nodes={nodes}
edges={edges}
nodes={nodes as any}
edges={edges as any}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
@@ -305,17 +325,28 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
{/* 상단 툴바 */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar />
<FlowToolbar validations={validations} />
</Panel>
</ReactFlow>
</div>
{/* 우측 속성 패널 */}
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
<div className="h-full w-[350px] border-l bg-white">
<div
style={{
height: "100%",
width: "350px",
display: "flex",
flexDirection: "column",
}}
className="border-l bg-white"
>
<PropertiesPanel />
</div>
)}
{/* 검증 알림 (우측 상단 플로팅) */}
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
</div>
);
}

View File

@@ -4,18 +4,27 @@
* 플로우 에디터 상단 툴바
*/
import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
import { useState } from "react";
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { useReactFlow } from "reactflow";
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
export function FlowToolbar() {
interface FlowToolbarProps {
validations?: FlowValidation[];
}
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
flowName,
setFlowName,
validateFlow,
nodes,
edges,
saveFlow,
exportFlow,
isSaving,
@@ -27,22 +36,31 @@ export function FlowToolbar() {
canRedo,
} = useFlowEditorStore();
const handleValidate = () => {
const result = validateFlow();
if (result.valid) {
alert("✅ 검증 성공! 오류가 없습니다.");
} else {
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
}
};
const [showSaveDialog, setShowSaveDialog] = useState(false);
const handleSave = async () => {
// 검증 수행
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
const summary = summarizeValidations(currentValidations);
// 오류나 경고가 있으면 다이얼로그 표시
if (currentValidations.length > 0) {
setShowSaveDialog(true);
return;
}
// 문제 없으면 바로 저장
await performSave();
};
const performSave = async () => {
const result = await saveFlow();
if (result.success) {
alert(`${result.message}\nFlow ID: ${result.flowId}`);
} else {
alert(`❌ 저장 실패\n\n${result.message}`);
}
setShowSaveDialog(false);
};
const handleExport = () => {
@@ -70,74 +88,76 @@ export function FlowToolbar() {
};
return (
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
{/* 플로우 이름 */}
<Input
value={flowName}
onChange={(e) => setFlowName(e.target.value)}
className="h-8 w-[200px] text-sm"
placeholder="플로우 이름"
<>
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
{/* 플로우 이름 */}
<Input
value={flowName}
onChange={(e) => setFlowName(e.target.value)}
className="h-8 w-[200px] text-sm"
placeholder="플로우 이름"
/>
<div className="h-6 w-px bg-gray-200" />
{/* 실행 취소/다시 실행 */}
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
<Redo2 className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={selectedNodes.length === 0}
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 줌 컨트롤 */}
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
<span className="text-xs"></span>
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 저장 */}
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
<Save className="h-4 w-4" />
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
</Button>
{/* 내보내기 */}
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
<Download className="h-4 w-4" />
<span className="text-xs">JSON</span>
</Button>
</div>
{/* 저장 확인 다이얼로그 */}
<SaveConfirmDialog
open={showSaveDialog}
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
onConfirm={performSave}
onCancel={() => setShowSaveDialog(false)}
/>
<div className="h-6 w-px bg-gray-200" />
{/* 실행 취소/다시 실행 */}
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
<Redo2 className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={selectedNodes.length === 0}
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 줌 컨트롤 */}
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
<span className="text-xs"></span>
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 저장 */}
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
<Save className="h-4 w-4" />
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
</Button>
{/* 내보내기 */}
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
<Download className="h-4 w-4" />
<span className="text-xs">JSON</span>
</Button>
<div className="h-6 w-px bg-gray-200" />
{/* 검증 */}
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
<FileCheck className="h-4 w-4" />
<span className="text-xs"></span>
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
/**
* 플로우 검증 결과 알림 (우측 상단 플로팅)
*/
import { memo, useState } from "react";
import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp } from "lucide-react";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import { summarizeValidations } from "@/lib/utils/flowValidation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface ValidationNotificationProps {
validations: FlowValidation[];
onNodeClick?: (nodeId: string) => void;
onClose?: () => void;
}
export const ValidationNotification = memo(
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const summary = summarizeValidations(validations);
if (validations.length === 0) {
return null;
}
const getTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
"parallel-conflict": "병렬 실행 충돌",
"missing-where": "WHERE 조건 누락",
"circular-reference": "순환 참조",
"data-source-mismatch": "데이터 소스 불일치",
"parallel-table-access": "병렬 테이블 접근",
};
return labels[type] || type;
};
// 타입별로 그룹화
const groupedValidations = validations.reduce((acc, validation) => {
if (!acc[validation.type]) {
acc[validation.type] = [];
}
acc[validation.type].push(validation);
return acc;
}, {} as Record<string, FlowValidation[]>);
return (
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
<div
className={cn(
"rounded-lg border-2 bg-white shadow-2xl",
summary.hasBlockingIssues
? "border-red-500"
: summary.warningCount > 0
? "border-yellow-500"
: "border-blue-500"
)}
>
{/* 헤더 */}
<div
className={cn(
"flex cursor-pointer items-center justify-between p-3",
summary.hasBlockingIssues
? "bg-red-50"
: summary.warningCount > 0
? "bg-yellow-50"
: "bg-blue-50"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{summary.hasBlockingIssues ? (
<AlertCircle className="h-5 w-5 text-red-600" />
) : summary.warningCount > 0 ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<Info className="h-5 w-5 text-blue-600" />
)}
<span className="text-sm font-semibold text-gray-900">
</span>
<div className="flex items-center gap-1">
{summary.errorCount > 0 && (
<Badge variant="destructive" className="h-5 text-[10px]">
{summary.errorCount}
</Badge>
)}
{summary.warningCount > 0 && (
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
{summary.warningCount}
</Badge>
)}
{summary.infoCount > 0 && (
<Badge variant="secondary" className="h-5 text-[10px]">
{summary.infoCount}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
)}
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="h-6 w-6 p-0 hover:bg-white/50"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 확장된 내용 */}
{isExpanded && (
<div className="max-h-[60vh] overflow-y-auto border-t">
<div className="p-2 space-y-2">
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
const firstValidation = typeValidations[0];
const Icon =
firstValidation.severity === "error"
? AlertCircle
: firstValidation.severity === "warning"
? AlertTriangle
: Info;
return (
<div key={type}>
{/* 타입 헤더 */}
<div
className={cn(
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
firstValidation.severity === "error"
? "bg-red-100 text-red-700"
: firstValidation.severity === "warning"
? "bg-yellow-100 text-yellow-700"
: "bg-blue-100 text-blue-700"
)}
>
<Icon className="h-3 w-3" />
{getTypeLabel(type)}
<span className="ml-auto">
{typeValidations.length}
</span>
</div>
{/* 검증 항목들 */}
<div className="space-y-1 pl-5">
{typeValidations.map((validation, index) => (
<div
key={index}
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
onClick={() => onNodeClick?.(validation.nodeId)}
>
<p className="text-gray-700 leading-relaxed">
{validation.message}
</p>
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
<div className="mt-1 text-[10px] text-gray-500">
: {validation.affectedNodes.length}
</div>
)}
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 요약 메시지 (닫혀있을 때) */}
{!isExpanded && (
<div className="border-t px-3 py-2">
<p className="text-xs text-gray-600">
{summary.hasBlockingIssues
? "⛔ 오류를 해결해야 저장할 수 있습니다"
: summary.warningCount > 0
? "⚠️ 경고 사항을 확인하세요"
: " 정보를 확인하세요"}
</p>
</div>
)}
</div>
</div>
);
}
);
ValidationNotification.displayName = "ValidationNotification";

View File

@@ -0,0 +1,201 @@
"use client";
/**
* 저장 확인 다이얼로그
*
* 경고가 있을 때 저장 전 확인을 받습니다
*/
import { memo } from "react";
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import { summarizeValidations } from "@/lib/utils/flowValidation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
interface SaveConfirmDialogProps {
open: boolean;
validations: FlowValidation[];
onConfirm: () => void;
onCancel: () => void;
}
export const SaveConfirmDialog = memo(
({ open, validations, onConfirm, onCancel }: SaveConfirmDialogProps) => {
const summary = summarizeValidations(validations);
// 오류가 있으면 저장 불가
if (summary.hasBlockingIssues) {
return (
<Dialog open={open} onOpenChange={onCancel}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertCircle className="h-5 w-5 text-red-500" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
{summary.errorCount}
</Badge>
{summary.warningCount > 0 && (
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
<AlertTriangle className="h-3 w-3" />
{summary.warningCount}
</Badge>
)}
</div>
<ScrollArea className="max-h-[300px]">
<div className="space-y-2">
{validations
.filter((v) => v.severity === "error")
.map((validation, index) => (
<div
key={index}
className="rounded-lg border-2 border-red-200 bg-red-50 p-3"
>
<div className="mb-1 text-xs font-medium text-red-600">
{validation.type}
</div>
<div className="text-sm text-red-800">
{validation.message}
</div>
</div>
))}
</div>
</ScrollArea>
<p className="text-xs text-gray-500">
.
.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
onClick={onCancel}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 경고만 있는 경우 - 저장 가능하지만 확인 필요
return (
<Dialog open={open} onOpenChange={onCancel}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{summary.warningCount + summary.infoCount}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center gap-2">
{summary.warningCount > 0 && (
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
<AlertTriangle className="h-3 w-3" />
{summary.warningCount}
</Badge>
)}
{summary.infoCount > 0 && (
<Badge variant="secondary" className="gap-1">
<Info className="h-3 w-3" />
{summary.infoCount}
</Badge>
)}
</div>
<ScrollArea className="max-h-[300px]">
<div className="space-y-2">
{validations
.filter((v) => v.severity === "warning")
.map((validation, index) => (
<div
key={index}
className="rounded-lg border-2 border-yellow-200 bg-yellow-50 p-3"
>
<div className="mb-1 text-xs font-medium text-yellow-600">
{validation.type}
</div>
<div className="text-sm text-yellow-800">
{validation.message}
</div>
</div>
))}
{validations
.filter((v) => v.severity === "info")
.map((validation, index) => (
<div
key={index}
className="rounded-lg border-2 border-blue-200 bg-blue-50 p-3"
>
<div className="mb-1 text-xs font-medium text-blue-600">
{validation.type}
</div>
<div className="text-sm text-blue-800">
{validation.message}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs text-gray-600">
.
<br />
?
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onCancel}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={onConfirm}
className="h-8 flex-1 bg-yellow-500 text-xs hover:bg-yellow-600 sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);
SaveConfirmDialog.displayName = "SaveConfirmDialog";

View File

@@ -0,0 +1,138 @@
"use client";
/**
* 검증 기능이 포함된 노드 래퍼
*
* 모든 노드에 경고/에러 아이콘을 표시하는 공통 래퍼
*/
import { memo, ReactNode } from "react";
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface NodeWithValidationProps {
nodeId: string;
validations: FlowValidation[];
children: ReactNode;
onClick?: () => void;
}
export const NodeWithValidation = memo(
({ nodeId, validations, children, onClick }: NodeWithValidationProps) => {
// 이 노드와 관련된 검증 결과 필터링
const nodeValidations = validations.filter(
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
);
// 가장 높은 심각도 결정
const hasError = nodeValidations.some((v) => v.severity === "error");
const hasWarning = nodeValidations.some((v) => v.severity === "warning");
const hasInfo = nodeValidations.some((v) => v.severity === "info");
if (nodeValidations.length === 0) {
return <>{children}</>;
}
// 심각도별 아이콘 및 색상
const getIconAndColor = () => {
if (hasError) {
return {
Icon: AlertCircle,
bgColor: "bg-red-500",
textColor: "text-red-700",
borderColor: "border-red-500",
hoverBgColor: "hover:bg-red-600",
};
}
if (hasWarning) {
return {
Icon: AlertTriangle,
bgColor: "bg-yellow-500",
textColor: "text-yellow-700",
borderColor: "border-yellow-500",
hoverBgColor: "hover:bg-yellow-600",
};
}
return {
Icon: Info,
bgColor: "bg-blue-500",
textColor: "text-blue-700",
borderColor: "border-blue-500",
hoverBgColor: "hover:bg-blue-600",
};
};
const { Icon, bgColor, textColor, borderColor, hoverBgColor } =
getIconAndColor();
return (
<div className="relative" onClick={onClick}>
{children}
{/* 경고 배지 */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ${bgColor} ${hoverBgColor} shadow-lg transition-all hover:scale-110`}
>
<Icon className="h-3.5 w-3.5 text-white" />
{nodeValidations.length > 1 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white text-[10px] font-bold shadow-sm">
{nodeValidations.length}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-xs border-0 p-0"
>
<div className={`rounded-lg border-2 ${borderColor} bg-white p-3 shadow-lg`}>
<div className="mb-2 flex items-center gap-2">
<Icon className={`h-4 w-4 ${textColor}`} />
<span className="font-semibold text-gray-900">
{hasError
? "오류"
: hasWarning
? "경고"
: "정보"} ({nodeValidations.length})
</span>
</div>
<div className="space-y-2">
{nodeValidations.map((validation, index) => (
<div
key={index}
className="rounded border-l-2 border-gray-300 bg-gray-50 p-2"
>
<div className="mb-1 text-xs font-medium text-gray-500">
{validation.type}
</div>
<div className="text-sm text-gray-700">
{validation.message}
</div>
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
<div className="mt-1 text-xs text-gray-500">
: {validation.affectedNodes.length}
</div>
)}
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
);
NodeWithValidation.displayName = "NodeWithValidation";

View File

@@ -28,9 +28,23 @@ export function PropertiesPanel() {
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
return (
<div className="flex h-full w-full flex-col">
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden'
}}
>
{/* 헤더 */}
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
<div
style={{
flexShrink: 0,
height: '64px'
}}
className="flex items-center justify-between border-b bg-white p-4"
>
<div>
<h3 className="text-sm font-semibold text-gray-900"></h3>
{selectedNode && (
@@ -44,11 +58,11 @@ export function PropertiesPanel() {
{/* 내용 - 스크롤 가능 영역 */}
<div
className="flex-1 overflow-y-scroll"
style={{
maxHeight: 'calc(100vh - 64px)',
overflowY: 'scroll',
WebkitOverflowScrolling: 'touch'
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden'
}}
>
{selectedNodes.length === 0 ? (

View File

@@ -0,0 +1,245 @@
"use client";
/**
* 플로우 검증 결과 패널
*
* 모든 검증 결과를 사이드바에 표시
*/
import { memo, useMemo } from "react";
import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronUp, X } from "lucide-react";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import { summarizeValidations } from "@/lib/utils/flowValidation";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useState } from "react";
interface ValidationPanelProps {
validations: FlowValidation[];
onNodeClick?: (nodeId: string) => void;
onClose?: () => void;
}
export const ValidationPanel = memo(
({ validations, onNodeClick, onClose }: ValidationPanelProps) => {
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
const summary = useMemo(
() => summarizeValidations(validations),
[validations]
);
// 타입별로 그룹화
const groupedValidations = useMemo(() => {
const groups = new Map<string, FlowValidation[]>();
for (const validation of validations) {
if (!groups.has(validation.type)) {
groups.set(validation.type, []);
}
groups.get(validation.type)!.push(validation);
}
return Array.from(groups.entries()).sort((a, b) => {
// 심각도 순으로 정렬
const severityOrder = { error: 0, warning: 1, info: 2 };
const aSeverity = Math.min(
...a[1].map((v) => severityOrder[v.severity])
);
const bSeverity = Math.min(
...b[1].map((v) => severityOrder[v.severity])
);
return aSeverity - bSeverity;
});
}, [validations]);
const toggleExpanded = (type: string) => {
setExpandedTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const getTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
"parallel-conflict": "병렬 실행 충돌",
"missing-where": "WHERE 조건 누락",
"circular-reference": "순환 참조",
"data-source-mismatch": "데이터 소스 불일치",
"parallel-table-access": "병렬 테이블 접근",
};
return labels[type] || type;
};
if (validations.length === 0) {
return (
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex flex-1 items-center justify-center p-8 text-center">
<div>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<Info className="h-6 w-6 text-green-600" />
</div>
<p className="text-sm font-medium text-gray-900"> </p>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* 요약 */}
<div className="border-b border-gray-200 bg-gray-50 p-4">
<div className="flex items-center gap-3">
{summary.errorCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
{summary.errorCount}
</Badge>
)}
{summary.warningCount > 0 && (
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
<AlertTriangle className="h-3 w-3" />
{summary.warningCount}
</Badge>
)}
{summary.infoCount > 0 && (
<Badge variant="secondary" className="gap-1">
<Info className="h-3 w-3" />
{summary.infoCount}
</Badge>
)}
</div>
{summary.hasBlockingIssues && (
<p className="mt-2 text-xs text-red-600">
</p>
)}
</div>
{/* 검증 결과 목록 */}
<ScrollArea className="flex-1">
<div className="p-2">
{groupedValidations.map(([type, typeValidations]) => {
const isExpanded = expandedTypes.has(type);
const firstValidation = typeValidations[0];
const Icon =
firstValidation.severity === "error"
? AlertCircle
: firstValidation.severity === "warning"
? AlertTriangle
: Info;
return (
<div key={type} className="mb-2">
{/* 그룹 헤더 */}
<button
onClick={() => toggleExpanded(type)}
className={cn(
"flex w-full items-center gap-2 rounded-lg p-3 text-left transition-colors",
firstValidation.severity === "error"
? "bg-red-50 hover:bg-red-100"
: firstValidation.severity === "warning"
? "bg-yellow-50 hover:bg-yellow-100"
: "bg-blue-50 hover:bg-blue-100"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
firstValidation.severity === "error"
? "text-red-600"
: firstValidation.severity === "warning"
? "text-yellow-600"
: "text-blue-600"
)}
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{getTypeLabel(type)}
</div>
<div className="text-xs text-gray-500">
{typeValidations.length}
</div>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
)}
</button>
{/* 상세 내용 */}
{isExpanded && (
<div className="mt-1 space-y-1 pl-6 pr-2">
{typeValidations.map((validation, index) => (
<div
key={index}
className="group cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-all hover:border-gray-300 hover:shadow-sm"
onClick={() => onNodeClick?.(validation.nodeId)}
>
<div className="text-xs text-gray-700">
{validation.message}
</div>
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
<div className="mt-2 flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">
: {validation.affectedNodes.length}
</Badge>
</div>
)}
<div className="mt-2 text-[10px] text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
);
ValidationPanel.displayName = "ValidationPanel";

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { ConditionNodeData } from "@/types/node-editor";
@@ -214,7 +213,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
};
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
@@ -420,6 +419,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { DataTransformNodeData } from "@/types/node-editor";
@@ -358,7 +357,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
};
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
@@ -454,6 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpD
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -216,7 +215,7 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
};
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 경고 */}
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
@@ -714,6 +713,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 WHERE .</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -49,9 +48,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
const [targetTable, setTargetTable] = useState(data.targetTable);
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false);
// 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]);
@@ -92,9 +88,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
setDisplayName(data.displayName || data.targetTable);
setTargetTable(data.targetTable);
setFieldMappings(data.fieldMappings || []);
setBatchSize(data.options?.batchSize?.toString() || "");
setIgnoreErrors(data.options?.ignoreErrors || false);
setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
}, [data]);
// 내부 DB 테이블 목록 로딩
@@ -439,11 +432,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
displayName: selectedTable.label,
targetTable: selectedTable.tableName,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
setTablesOpen(false);
@@ -517,39 +505,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
updateNode(nodeId, { fieldMappings: newMappings });
};
const handleBatchSizeChange = (newBatchSize: string) => {
setBatchSize(newBatchSize);
updateNode(nodeId, {
options: {
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
};
const handleIgnoreErrorsChange = (checked: boolean) => {
setIgnoreErrors(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors: checked,
ignoreDuplicates,
},
});
};
const handleIgnoreDuplicatesChange = (checked: boolean) => {
setIgnoreDuplicates(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates: checked,
},
});
};
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
// 🔥 타겟 타입 변경 핸들러
@@ -575,17 +530,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
}
updates.fieldMappings = fieldMappings;
updates.options = {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
};
updateNode(nodeId, updates);
};
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 🔥 타겟 타입 선택 */}
<div>
@@ -753,11 +703,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
externalDbType: selectedConnection?.db_type,
externalTargetTable: undefined,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
disabled={externalConnectionsLoading}
@@ -797,11 +742,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
externalConnectionId: selectedExternalConnectionId,
externalTargetTable: value,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
ignoreDuplicates,
},
});
}}
disabled={externalTablesLoading}
@@ -1240,51 +1180,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div>
)}
{/* 옵션 */}
<div>
<h3 className="mb-3 text-sm font-semibold"></h3>
<div className="space-y-3">
<div>
<Label htmlFor="batchSize" className="text-xs">
</Label>
<Input
id="batchSize"
type="number"
value={batchSize}
onChange={(e) => handleBatchSizeChange(e.target.value)}
className="mt-1"
placeholder="한 번에 처리할 레코드 수"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreDuplicates"
checked={ignoreDuplicates}
onCheckedChange={(checked) => handleIgnoreDuplicatesChange(checked as boolean)}
/>
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreErrors"
checked={ignoreErrors}
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
</Label>
</div>
</div>
</div>
{/* 저장 버튼 */}
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
.
@@ -1292,6 +1187,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
💡 .
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, Search } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
@@ -262,7 +261,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
@@ -633,6 +632,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Check, ChevronsUpDown, Table, FileText } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -65,8 +64,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const [targetTable, setTargetTable] = useState(data.targetTable);
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
// 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]);
@@ -108,8 +105,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
setTargetTable(data.targetTable);
setFieldMappings(data.fieldMappings || []);
setWhereConditions(data.whereConditions || []);
setBatchSize(data.options?.batchSize?.toString() || "");
setIgnoreErrors(data.options?.ignoreErrors || false);
}, [data]);
// 내부 DB 테이블 목록 로딩
@@ -368,10 +363,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
targetTable: newTableName,
fieldMappings,
whereConditions,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors,
},
});
setTablesOpen(false);
@@ -511,31 +502,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
updateNode(nodeId, { whereConditions: newConditions });
};
const handleBatchSizeChange = (newBatchSize: string) => {
setBatchSize(newBatchSize);
updateNode(nodeId, {
options: {
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
ignoreErrors,
},
});
};
const handleIgnoreErrorsChange = (checked: boolean) => {
setIgnoreErrors(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
ignoreErrors: checked,
},
});
};
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4 pb-8">
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
@@ -1268,38 +1238,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</div>
)}
{/* 옵션 */}
<div>
<h3 className="mb-3 text-sm font-semibold"></h3>
<div className="space-y-3">
<div>
<Label htmlFor="batchSize" className="text-xs">
</Label>
<Input
id="batchSize"
type="number"
value={batchSize}
onChange={(e) => handleBatchSizeChange(e.target.value)}
className="mt-1"
placeholder="예: 100"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="ignoreErrors"
checked={ignoreErrors}
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
/>
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -51,8 +50,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const [conflictKeys, setConflictKeys] = useState<string[]>(data.conflictKeys || []);
const [conflictKeyLabels, setConflictKeyLabels] = useState<string[]>(data.conflictKeyLabels || []);
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
@@ -95,8 +92,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
setConflictKeys(data.conflictKeys || []);
setConflictKeyLabels(data.conflictKeyLabels || []);
setFieldMappings(data.fieldMappings || []);
setBatchSize(data.options?.batchSize?.toString() || "");
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
}, [data]);
// 🔥 내부 DB 테이블 목록 로딩
@@ -363,10 +358,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
conflictKeys,
conflictKeyLabels,
fieldMappings,
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
updateOnConflict,
},
});
setTablesOpen(false);
@@ -460,30 +451,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
updateNode(nodeId, { fieldMappings: newMappings });
};
const handleBatchSizeChange = (newBatchSize: string) => {
setBatchSize(newBatchSize);
updateNode(nodeId, {
options: {
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
updateOnConflict,
},
});
};
const handleUpdateOnConflictChange = (checked: boolean) => {
setUpdateOnConflict(checked);
updateNode(nodeId, {
options: {
batchSize: batchSize ? parseInt(batchSize) : undefined,
updateOnConflict: checked,
},
});
};
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
return (
<ScrollArea className="h-full">
<div>
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
@@ -1122,38 +1093,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
)}
</div>
{/* 옵션 */}
<div>
<h3 className="mb-3 text-sm font-semibold"></h3>
<div className="space-y-3">
<div>
<Label htmlFor="batchSize" className="text-xs">
</Label>
<Input
id="batchSize"
type="number"
value={batchSize}
onChange={(e) => setBatchSize(e.target.value)}
className="mt-1"
placeholder="예: 100"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="updateOnConflict"
checked={updateOnConflict}
onCheckedChange={(checked) => setUpdateOnConflict(checked as boolean)}
/>
<Label htmlFor="updateOnConflict" className="cursor-pointer text-xs font-normal">
(ON CONFLICT DO UPDATE)
</Label>
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
);
}