ERP-node/frontend/components/dataflow/node-editor/panels/properties/RestAPISourceProperties.tsx

249 lines
7.7 KiB
TypeScript
Raw Normal View History

feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
"use client";
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { RestAPISourceNodeData } from "@/types/node-editor";
import { Globe, Plus, Trash2 } from "lucide-react";
interface RestAPISourcePropertiesProps {
nodeId: string;
data: RestAPISourceNodeData;
}
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const AUTH_TYPES = [
{ value: "none", label: "인증 없음" },
{ value: "bearer", label: "Bearer Token" },
{ value: "basic", label: "Basic Auth" },
{ value: "apikey", label: "API Key" },
];
export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertiesProps) {
const { updateNode } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(data.displayName || "");
const [url, setUrl] = useState(data.url || "");
const [method, setMethod] = useState(data.method || "GET");
const [headers, setHeaders] = useState(data.headers || {});
const [newHeaderKey, setNewHeaderKey] = useState("");
const [newHeaderValue, setNewHeaderValue] = useState("");
const [body, setBody] = useState(JSON.stringify(data.body || {}, null, 2));
const [authType, setAuthType] = useState(data.authentication?.type || "none");
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
useEffect(() => {
setDisplayName(data.displayName || "");
setUrl(data.url || "");
setMethod(data.method || "GET");
setHeaders(data.headers || {});
setBody(JSON.stringify(data.body || {}, null, 2));
setAuthType(data.authentication?.type || "none");
setAuthToken(data.authentication?.token || "");
setTimeout(data.timeout?.toString() || "30000");
setResponseMapping(data.responseMapping || "");
}, [data]);
const handleApply = () => {
let parsedBody = {};
try {
parsedBody = body.trim() ? JSON.parse(body) : {};
} catch (e) {
alert("Body JSON 형식이 올바르지 않습니다.");
return;
}
updateNode(nodeId, {
displayName,
url,
method: method as any,
headers,
body: parsedBody,
authentication: {
type: authType as any,
token: authToken || undefined,
},
timeout: parseInt(timeout) || 30000,
responseMapping,
});
};
const addHeader = () => {
if (newHeaderKey.trim() && newHeaderValue.trim()) {
setHeaders({ ...headers, [newHeaderKey.trim()]: newHeaderValue.trim() });
setNewHeaderKey("");
setNewHeaderValue("");
}
};
const removeHeader = (key: string) => {
const newHeaders = { ...headers };
delete newHeaders[key];
setHeaders(newHeaders);
};
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
<Globe className="h-4 w-4 text-teal-600" />
<span className="font-semibold text-teal-600">REST API </span>
</div>
<div>
<Label htmlFor="displayName" className="text-xs">
</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="노드에 표시될 이름"
className="mt-1 text-sm"
/>
</div>
<div>
<Label htmlFor="url" className="text-xs">
API URL
</Label>
<Input
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/data"
className="mt-1 text-sm"
/>
</div>
<div>
<Label className="text-xs">HTTP </Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<div className="mt-1 space-y-2">
<div className="flex gap-2">
<Input
value={newHeaderKey}
onChange={(e) => setNewHeaderKey(e.target.value)}
placeholder="Key"
className="text-sm"
/>
<Input
value={newHeaderValue}
onChange={(e) => setNewHeaderValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addHeader()}
placeholder="Value"
className="text-sm"
/>
<Button size="sm" onClick={addHeader}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[100px] space-y-1 overflow-y-auto">
{Object.entries(headers).map(([key, value]) => (
<div key={key} className="flex items-center justify-between rounded bg-teal-50 px-2 py-1">
<span className="text-xs">
<span className="font-medium">{key}:</span> {value}
</span>
<Button variant="ghost" size="sm" onClick={() => removeHeader(key)}>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</div>
))}
</div>
</div>
</div>
{(method === "POST" || method === "PUT" || method === "PATCH") && (
<div>
<Label htmlFor="body" className="text-xs">
Body (JSON)
</Label>
<Textarea
id="body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder='{"key": "value"}'
className="mt-1 font-mono text-sm"
rows={5}
/>
</div>
)}
<div>
<Label className="text-xs"></Label>
<Select value={authType} onValueChange={setAuthType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{authType !== "none" && (
<Input
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder="토큰/키 입력"
className="mt-2 text-sm"
type="password"
/>
)}
</div>
<div>
<Label htmlFor="timeout" className="text-xs">
(ms)
</Label>
<Input
id="timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
className="mt-1 text-sm"
/>
</div>
<div>
<Label htmlFor="responseMapping" className="text-xs">
(JSON )
</Label>
<Input
id="responseMapping"
value={responseMapping}
onChange={(e) => setResponseMapping(e.target.value)}
placeholder="예: data.items"
className="mt-1 text-sm"
/>
</div>
<Button onClick={handleApply} className="w-full">
</Button>
</div>
);
}