액션 노드들 로직 구현
This commit is contained in:
@@ -5,14 +5,20 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } 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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
interface DeleteActionPropertiesProps {
|
||||
nodeId: string;
|
||||
@@ -29,18 +35,157 @@ const OPERATORS = [
|
||||
] as const;
|
||||
|
||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||||
data.externalConnectionId,
|
||||
);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음)
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
|
||||
// 🔥 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "internal") {
|
||||
loadTables();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 커넥션 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 테이블 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId) {
|
||||
loadExternalTables(selectedExternalConnectionId);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// 🔥 외부 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
||||
const cached = getExternalConnectionsCache();
|
||||
if (cached) {
|
||||
setExternalConnections(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getTestedExternalConnections();
|
||||
setExternalConnections(data);
|
||||
} catch (error) {
|
||||
console.error("외부 커넥션 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalTables = async (connectionId: number) => {
|
||||
try {
|
||||
setExternalTablesLoading(true);
|
||||
const data = await getExternalTables(connectionId);
|
||||
setExternalTables(data);
|
||||
} catch (error) {
|
||||
console.error("외부 테이블 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||||
try {
|
||||
setExternalColumnsLoading(true);
|
||||
const data = await getExternalColumns(connectionId, tableName);
|
||||
setExternalColumns(data);
|
||||
} catch (error) {
|
||||
console.error("외부 컬럼 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
targetTable: newType === "internal" ? targetTable : undefined,
|
||||
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
|
||||
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
|
||||
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
|
||||
apiAuthType: newType === "api" ? apiAuthType : undefined,
|
||||
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
|
||||
apiHeaders: newType === "api" ? apiHeaders : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 🔥 테이블 목록 로딩
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||
|
||||
setTargetTable(tableName);
|
||||
setSelectedTableLabel(label);
|
||||
setTablesOpen(false);
|
||||
|
||||
updateNode(nodeId, {
|
||||
targetTable: tableName,
|
||||
displayName: label,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
@@ -103,17 +248,385 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="targetTable" className="text-xs">
|
||||
타겟 테이블
|
||||
</Label>
|
||||
<Input
|
||||
id="targetTable"
|
||||
value={targetTable}
|
||||
onChange={(e) => setTargetTable(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||||
>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||||
)}
|
||||
>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||
>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table: any) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableLabel || table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 외부 DB 설정 */}
|
||||
{targetType === "external" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||||
<Select
|
||||
value={selectedExternalConnectionId?.toString()}
|
||||
onValueChange={(value) => {
|
||||
const connectionId = parseInt(value);
|
||||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||||
setSelectedExternalConnectionId(connectionId);
|
||||
setExternalTargetTable("");
|
||||
setExternalColumns([]);
|
||||
updateNode(nodeId, {
|
||||
externalConnectionId: connectionId,
|
||||
externalConnectionName: selectedConnection?.name,
|
||||
externalDbType: selectedConnection?.db_type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnectionsLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalConnections.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||||
) : (
|
||||
externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.db_type}</span>
|
||||
<span className="text-gray-500">-</span>
|
||||
<span>{conn.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedExternalConnectionId && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={externalTargetTable}
|
||||
onValueChange={(value) => {
|
||||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||||
setExternalTargetTable(value);
|
||||
updateNode(nodeId, {
|
||||
externalTargetTable: value,
|
||||
externalTargetSchema: selectedTable?.schema,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalTablesLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalTables.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||||
) : (
|
||||
externalTables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalTargetTable && externalColumns.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{externalColumns.map((col) => (
|
||||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 REST API 설정 (DELETE는 간단함) */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||||
<Select
|
||||
value={apiAuthType}
|
||||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||||
setApiAuthType(value);
|
||||
updateNode(nodeId, { apiAuthType: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{apiAuthType !== "none" && (
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||||
|
||||
{apiAuthType === "bearer" && (
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={(apiAuthConfig as any)?.token || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { token: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiAuthType === "basic" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="사용자명"
|
||||
value={(apiAuthConfig as any)?.username || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={(apiAuthConfig as any)?.password || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiAuthType === "apikey" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="헤더 이름 (예: X-API-Key)"
|
||||
value={(apiAuthConfig as any)?.headerName || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Key"
|
||||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더 이름"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
newHeaders[e.target.value] = value;
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더 값"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders, "": "" };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
헤더 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
interface UpdateActionPropertiesProps {
|
||||
nodeId: string;
|
||||
@@ -54,7 +56,10 @@ const OPERATORS = [
|
||||
] as const;
|
||||
|
||||
export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
@@ -63,7 +68,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||
|
||||
// 테이블 관련 상태
|
||||
// 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
@@ -75,6 +80,26 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||||
data.externalConnectionId,
|
||||
);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// 🔥 REST API 관련 상태
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiMethod, setApiMethod] = useState<"PUT" | "PATCH">(data.apiMethod || "PUT");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
@@ -85,17 +110,40 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
// 내부 DB 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
if (targetType === "internal") {
|
||||
loadTables();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
// 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
|
||||
useEffect(() => {
|
||||
if (targetTable) {
|
||||
if (targetType === "internal" && targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetTable]);
|
||||
}, [targetType, targetTable]);
|
||||
|
||||
// 🔥 외부 DB: 커넥션 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 DB: 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId) {
|
||||
loadExternalTables(selectedExternalConnectionId);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// 🔥 외부 DB: 컬럼 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
@@ -201,6 +249,54 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
}
|
||||
};
|
||||
|
||||
// 🔥 외부 DB 커넥션 목록 로딩
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
||||
// 캐시 확인
|
||||
const cached = getExternalConnectionsCache();
|
||||
if (cached) {
|
||||
setExternalConnections(cached);
|
||||
setExternalConnectionsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = await getTestedExternalConnections();
|
||||
setExternalConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("외부 커넥션 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔥 외부 DB 테이블 목록 로딩
|
||||
const loadExternalTables = async (connectionId: number) => {
|
||||
try {
|
||||
setExternalTablesLoading(true);
|
||||
const tables = await getExternalTables(connectionId);
|
||||
setExternalTables(tables);
|
||||
} catch (error) {
|
||||
console.error("외부 테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔥 외부 DB 컬럼 목록 로딩
|
||||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||||
try {
|
||||
setExternalColumnsLoading(true);
|
||||
const columns = await getExternalColumns(connectionId, tableName);
|
||||
setExternalColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("외부 컬럼 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
@@ -302,6 +398,34 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
setFieldMappings(newMappings);
|
||||
};
|
||||
|
||||
// 🔥 타겟 타입 변경 핸들러
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
...(newType === "internal" && {
|
||||
targetTable: data.targetTable,
|
||||
targetConnection: data.targetConnection,
|
||||
displayName: data.displayName,
|
||||
}),
|
||||
...(newType === "external" && {
|
||||
externalConnectionId: data.externalConnectionId,
|
||||
externalConnectionName: data.externalConnectionName,
|
||||
externalDbType: data.externalDbType,
|
||||
externalTargetTable: data.externalTargetTable,
|
||||
externalTargetSchema: data.externalTargetSchema,
|
||||
}),
|
||||
...(newType === "api" && {
|
||||
apiEndpoint: data.apiEndpoint,
|
||||
apiMethod: data.apiMethod,
|
||||
apiAuthType: data.apiAuthType,
|
||||
apiAuthConfig: data.apiAuthConfig,
|
||||
apiHeaders: data.apiHeaders,
|
||||
apiBodyTemplate: data.apiBodyTemplate,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
@@ -391,58 +515,433 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 Combobox */}
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* 내부 데이터베이스 */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||
</button>
|
||||
|
||||
{/* 외부 데이터베이스 */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||
</button>
|
||||
|
||||
{/* REST API */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||
>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */}
|
||||
{targetType === "external" && (
|
||||
<>
|
||||
{/* 외부 커넥션 선택 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||||
<Select
|
||||
value={selectedExternalConnectionId?.toString()}
|
||||
onValueChange={(value) => {
|
||||
const connectionId = parseInt(value);
|
||||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||||
setSelectedExternalConnectionId(connectionId);
|
||||
setExternalTargetTable("");
|
||||
setExternalColumns([]);
|
||||
updateNode(nodeId, {
|
||||
externalConnectionId: connectionId,
|
||||
externalConnectionName: selectedConnection?.name,
|
||||
externalDbType: selectedConnection?.db_type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnectionsLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalConnections.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||||
) : (
|
||||
externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.db_type}</span>
|
||||
<span className="text-gray-500">-</span>
|
||||
<span>{conn.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 외부 테이블 선택 */}
|
||||
{selectedExternalConnectionId && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={externalTargetTable}
|
||||
onValueChange={(value) => {
|
||||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||||
setExternalTargetTable(value);
|
||||
updateNode(nodeId, {
|
||||
externalTargetTable: value,
|
||||
externalTargetSchema: selectedTable?.schema,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalTablesLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalTables.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||||
) : (
|
||||
externalTables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 외부 컬럼 표시 */}
|
||||
{externalTargetTable && externalColumns.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{externalColumns.map((col) => (
|
||||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 REST API 설정 */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
{/* API 엔드포인트 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={apiMethod}
|
||||
onValueChange={(value: "PUT" | "PATCH") => {
|
||||
setApiMethod(value);
|
||||
updateNode(nodeId, { apiMethod: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||||
<Select
|
||||
value={apiAuthType}
|
||||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||||
setApiAuthType(value);
|
||||
updateNode(nodeId, { apiAuthType: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 인증 설정 */}
|
||||
{apiAuthType !== "none" && (
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||||
|
||||
{apiAuthType === "bearer" && (
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={(apiAuthConfig as any)?.token || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { token: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiAuthType === "basic" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="사용자명"
|
||||
value={(apiAuthConfig as any)?.username || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={(apiAuthConfig as any)?.password || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiAuthType === "apikey" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="헤더 이름 (예: X-API-Key)"
|
||||
value={(apiAuthConfig as any)?.headerName || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Key"
|
||||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커스텀 헤더 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더 이름"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
newHeaders[e.target.value] = value;
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더 값"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders, "": "" };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
헤더 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요청 바디 설정 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">
|
||||
요청 바디 템플릿
|
||||
<span className="ml-1 text-gray-500">{`{{fieldName}}`}으로 소스 필드 참조</span>
|
||||
</Label>
|
||||
<textarea
|
||||
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}",\n "email": "{{email}}"\n}`}
|
||||
value={apiBodyTemplate}
|
||||
onChange={(e) => {
|
||||
setApiBodyTemplate(e.target.value);
|
||||
updateNode(nodeId, { apiBodyTemplate: e.target.value });
|
||||
}}
|
||||
className="w-full rounded border p-2 font-mono text-xs"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
소스 데이터의 필드명을 {`{{필드명}}`} 형태로 참조할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -589,129 +1088,133 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">업데이트 필드</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
|
||||
{targetType !== "api" && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">업데이트 필드</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
❌ 컬럼 정보를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{fieldMappings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
❌ 컬럼 정보를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{fieldMappings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
업데이트할 필드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
업데이트할 필드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
interface UpsertActionPropertiesProps {
|
||||
nodeId: string;
|
||||
@@ -39,7 +41,10 @@ interface ColumnInfo {
|
||||
}
|
||||
|
||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
@@ -49,6 +54,26 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||||
data.externalConnectionId,
|
||||
);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// 🔥 REST API 관련 상태
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "PUT");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||
|
||||
// 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
@@ -72,17 +97,40 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
if (targetType === "internal") {
|
||||
loadTables();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
// 🔥 내부 DB 타겟 테이블 변경 시 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetTable) {
|
||||
if (targetType === "internal" && targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetTable]);
|
||||
}, [targetType, targetTable]);
|
||||
|
||||
// 🔥 외부 커넥션 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 테이블 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId) {
|
||||
loadExternalTables(selectedExternalConnectionId);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// 🔥 외부 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
@@ -162,6 +210,66 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
setSourceFields(uniqueFields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 🔥 외부 커넥션 로딩 함수
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
||||
const cached = getExternalConnectionsCache();
|
||||
if (cached) {
|
||||
setExternalConnections(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getTestedExternalConnections();
|
||||
setExternalConnections(data);
|
||||
} catch (error) {
|
||||
console.error("외부 커넥션 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalTables = async (connectionId: number) => {
|
||||
try {
|
||||
setExternalTablesLoading(true);
|
||||
const data = await getExternalTables(connectionId);
|
||||
setExternalTables(data);
|
||||
} catch (error) {
|
||||
console.error("외부 테이블 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||||
try {
|
||||
setExternalColumnsLoading(true);
|
||||
const data = await getExternalColumns(connectionId, tableName);
|
||||
setExternalColumns(data);
|
||||
} catch (error) {
|
||||
console.error("외부 컬럼 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
targetTable: newType === "internal" ? targetTable : undefined,
|
||||
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
|
||||
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
|
||||
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
|
||||
apiMethod: newType === "api" ? apiMethod : undefined,
|
||||
apiAuthType: newType === "api" ? apiAuthType : undefined,
|
||||
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
|
||||
apiHeaders: newType === "api" ? apiHeaders : undefined,
|
||||
apiBodyTemplate: newType === "api" ? apiBodyTemplate : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
@@ -355,58 +463,425 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 Combobox */}
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||
>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 외부 DB 설정 (INSERT 노드와 동일) */}
|
||||
{targetType === "external" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||||
<Select
|
||||
value={selectedExternalConnectionId?.toString()}
|
||||
onValueChange={(value) => {
|
||||
const connectionId = parseInt(value);
|
||||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||||
setSelectedExternalConnectionId(connectionId);
|
||||
setExternalTargetTable("");
|
||||
setExternalColumns([]);
|
||||
updateNode(nodeId, {
|
||||
externalConnectionId: connectionId,
|
||||
externalConnectionName: selectedConnection?.name,
|
||||
externalDbType: selectedConnection?.db_type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnectionsLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalConnections.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||||
) : (
|
||||
externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.db_type}</span>
|
||||
<span className="text-gray-500">-</span>
|
||||
<span>{conn.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedExternalConnectionId && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={externalTargetTable}
|
||||
onValueChange={(value) => {
|
||||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||||
setExternalTargetTable(value);
|
||||
updateNode(nodeId, {
|
||||
externalTargetTable: value,
|
||||
externalTargetSchema: selectedTable?.schema,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalTablesLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalTables.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||||
) : (
|
||||
externalTables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalTargetTable && externalColumns.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{externalColumns.map((col) => (
|
||||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 REST API 설정 (INSERT 노드와 동일) */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={apiMethod}
|
||||
onValueChange={(value: "POST" | "PUT" | "PATCH") => {
|
||||
setApiMethod(value);
|
||||
updateNode(nodeId, { apiMethod: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||||
<Select
|
||||
value={apiAuthType}
|
||||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||||
setApiAuthType(value);
|
||||
updateNode(nodeId, { apiAuthType: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{apiAuthType !== "none" && (
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||||
|
||||
{apiAuthType === "bearer" && (
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={(apiAuthConfig as any)?.token || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { token: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiAuthType === "basic" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="사용자명"
|
||||
value={(apiAuthConfig as any)?.username || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={(apiAuthConfig as any)?.password || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiAuthType === "apikey" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="헤더 이름 (예: X-API-Key)"
|
||||
value={(apiAuthConfig as any)?.headerName || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Key"
|
||||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더 이름"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
newHeaders[e.target.value] = value;
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더 값"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders, "": "" };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
헤더 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">
|
||||
요청 바디 템플릿
|
||||
<span className="ml-1 text-gray-500">{`{{fieldName}}`}으로 소스 필드 참조</span>
|
||||
</Label>
|
||||
<textarea
|
||||
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}",\n "email": "{{email}}"\n}`}
|
||||
value={apiBodyTemplate}
|
||||
onChange={(e) => {
|
||||
setApiBodyTemplate(e.target.value);
|
||||
updateNode(nodeId, { apiBodyTemplate: e.target.value });
|
||||
}}
|
||||
className="w-full rounded border p-2 font-mono text-xs"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
소스 데이터의 필드명을 {`{{필드명}}`} 형태로 참조할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user