액션 노드들 로직 구현
This commit is contained in:
parent
37e018b33c
commit
258bd80201
|
|
@ -103,15 +103,34 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// 쿼리 타입 확인 (DML인지 SELECT인지)
|
// 쿼리 타입 확인
|
||||||
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
|
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
|
||||||
|
const isCOMMIT = /^\s*COMMIT/i.test(query);
|
||||||
|
const isROLLBACK = /^\s*ROLLBACK/i.test(query);
|
||||||
|
|
||||||
|
// 🔥 COMMIT/ROLLBACK 명령은 직접 실행
|
||||||
|
if (isCOMMIT || isROLLBACK) {
|
||||||
|
if (isCOMMIT) {
|
||||||
|
await this.connection!.commit();
|
||||||
|
console.log("✅ Oracle COMMIT 실행됨");
|
||||||
|
} else {
|
||||||
|
await this.connection!.rollback();
|
||||||
|
console.log("⚠️ Oracle ROLLBACK 실행됨");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
rowCount: 0,
|
||||||
|
fields: [],
|
||||||
|
affectedRows: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Oracle XE 21c 쿼리 실행 옵션
|
// Oracle XE 21c 쿼리 실행 옵션
|
||||||
const options: any = {
|
const options: any = {
|
||||||
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
||||||
maxRows: 10000, // XE 제한 고려
|
maxRows: 10000, // XE 제한 고려
|
||||||
fetchArraySize: 100,
|
fetchArraySize: 100,
|
||||||
autoCommit: isDML, // ✅ DML 쿼리는 자동 커밋
|
autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Oracle 쿼리 실행:", {
|
console.log("Oracle 쿼리 실행:", {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
import {
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
DatabaseConnector,
|
||||||
|
ConnectionConfig,
|
||||||
|
QueryResult,
|
||||||
|
} from "../interfaces/DatabaseConnector";
|
||||||
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
|
||||||
export interface RestApiConfig {
|
export interface RestApiConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -26,10 +30,10 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
baseURL: config.baseUrl,
|
baseURL: config.baseUrl,
|
||||||
timeout: config.timeout || 30000,
|
timeout: config.timeout || 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
'Accept': 'application/json'
|
Accept: "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 요청/응답 인터셉터 설정
|
// 요청/응답 인터셉터 설정
|
||||||
|
|
@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 요청 인터셉터
|
// 요청 인터셉터
|
||||||
this.httpClient.interceptors.request.use(
|
this.httpClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`
|
||||||
|
);
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('[RestApiConnector] 요청 오류:', error);
|
console.error("[RestApiConnector] 요청 오류:", error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 응답 인터셉터
|
// 응답 인터셉터
|
||||||
this.httpClient.interceptors.response.use(
|
this.httpClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 응답: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
console.error(
|
||||||
|
"[RestApiConnector] 응답 오류:",
|
||||||
|
error.response?.status,
|
||||||
|
error.response?.statusText
|
||||||
|
);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 연결 테스트 - 기본 엔드포인트 호출
|
// 연결 테스트 - 기본 엔드포인트 호출
|
||||||
await this.httpClient.get('/health', { timeout: 5000 });
|
await this.httpClient.get("/health", { timeout: 5000 });
|
||||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
|
console.error(
|
||||||
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'REST API 연결이 성공했습니다.',
|
message: "REST API 연결이 성공했습니다.",
|
||||||
details: {
|
details: {
|
||||||
response_time: Date.now()
|
response_time: Date.now(),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "REST API 연결에 실패했습니다.",
|
||||||
details: {
|
details: {
|
||||||
response_time: Date.now()
|
response_time: Date.now(),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
// 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음)
|
||||||
|
async executeQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||||
|
// REST API는 executeRequest를 사용해야 함
|
||||||
|
throw new Error(
|
||||||
|
"REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 실제 REST API 요청을 위한 메서드
|
||||||
|
async executeRequest(
|
||||||
|
endpoint: string,
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||||
|
data?: any
|
||||||
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let response: AxiosResponse;
|
let response: AxiosResponse;
|
||||||
|
|
||||||
// HTTP 메서드에 따른 요청 실행
|
// HTTP 메서드에 따른 요청 실행
|
||||||
switch (method.toUpperCase()) {
|
switch (method.toUpperCase()) {
|
||||||
case 'GET':
|
case "GET":
|
||||||
response = await this.httpClient.get(endpoint);
|
response = await this.httpClient.get(endpoint);
|
||||||
break;
|
break;
|
||||||
case 'POST':
|
case "POST":
|
||||||
response = await this.httpClient.post(endpoint, data);
|
response = await this.httpClient.post(endpoint, data);
|
||||||
break;
|
break;
|
||||||
case 'PUT':
|
case "PUT":
|
||||||
response = await this.httpClient.put(endpoint, data);
|
response = await this.httpClient.put(endpoint, data);
|
||||||
break;
|
break;
|
||||||
case 'DELETE':
|
case "DELETE":
|
||||||
response = await this.httpClient.delete(endpoint);
|
response = await this.httpClient.delete(endpoint);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||||
type: typeof responseData,
|
type: typeof responseData,
|
||||||
isArray: Array.isArray(responseData),
|
isArray: Array.isArray(responseData),
|
||||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
keys:
|
||||||
responseData: responseData
|
typeof responseData === "object"
|
||||||
|
? Object.keys(responseData)
|
||||||
|
: "not object",
|
||||||
|
responseData: responseData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답 데이터 처리
|
// 응답 데이터 처리
|
||||||
let rows: any[];
|
let rows: any[];
|
||||||
if (Array.isArray(responseData)) {
|
if (Array.isArray(responseData)) {
|
||||||
rows = responseData;
|
rows = responseData;
|
||||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
responseData.data &&
|
||||||
|
Array.isArray(responseData.data)
|
||||||
|
) {
|
||||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||||
rows = responseData.data;
|
rows = responseData.data;
|
||||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
responseData.data &&
|
||||||
|
typeof responseData.data === "object"
|
||||||
|
) {
|
||||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||||
rows = [responseData.data];
|
rows = [responseData.data];
|
||||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
typeof responseData === "object" &&
|
||||||
|
!Array.isArray(responseData)
|
||||||
|
) {
|
||||||
// 단일 객체 응답인 경우
|
// 단일 객체 응답인 경우
|
||||||
rows = [responseData];
|
rows = [responseData];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
|
|
||||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||||
rowsLength: rows.length,
|
rowsLength: rows.length,
|
||||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
firstRow: rows.length > 0 ? rows[0] : "no data",
|
||||||
allRows: rows
|
allRows: rows,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||||
|
|
@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
method,
|
method,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
executionTime: `${executionTime}ms`
|
executionTime: `${executionTime}ms`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: rows,
|
rows: rows,
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
fields:
|
||||||
|
rows.length > 0
|
||||||
|
? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" }))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
console.error(
|
||||||
|
`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
throw new Error(
|
||||||
|
`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
throw new Error(
|
||||||
|
`REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 일반적인 REST API 엔드포인트들을 반환
|
// 일반적인 REST API 엔드포인트들을 반환
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
table_name: '/api/users',
|
table_name: "/api/users",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '사용자 정보 API'
|
description: "사용자 정보 API",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table_name: '/api/data',
|
table_name: "/api/data",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '기본 데이터 API'
|
description: "기본 데이터 API",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table_name: '/api/custom',
|
table_name: "/api/custom",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '사용자 정의 엔드포인트'
|
description: "사용자 정의 엔드포인트",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
async getColumns(endpoint: string): Promise<any[]> {
|
async getColumns(endpoint: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||||
const result = await this.executeQuery(endpoint, 'GET');
|
const result = await this.executeRequest(endpoint, "GET");
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
const sampleRow = result.rows[0];
|
const sampleRow = result.rows[0];
|
||||||
return Object.keys(sampleRow).map(key => ({
|
return Object.keys(sampleRow).map((key) => ({
|
||||||
column_name: key,
|
column_name: key,
|
||||||
data_type: typeof sampleRow[key],
|
data_type: typeof sampleRow[key],
|
||||||
is_nullable: 'YES',
|
is_nullable: "YES",
|
||||||
column_default: null,
|
column_default: null,
|
||||||
description: `${key} 필드`
|
description: `${key} 필드`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
console.error(
|
||||||
|
`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST API 전용 메서드들
|
// REST API 전용 메서드들
|
||||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
async getData(
|
||||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
endpoint: string,
|
||||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
params?: Record<string, any>
|
||||||
|
): Promise<any[]> {
|
||||||
|
const queryString = params
|
||||||
|
? "?" + new URLSearchParams(params).toString()
|
||||||
|
: "";
|
||||||
|
const result = await this.executeRequest(endpoint + queryString, "GET");
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async postData(endpoint: string, data: any): Promise<any> {
|
async postData(endpoint: string, data: any): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
const result = await this.executeRequest(endpoint, "POST", data);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async putData(endpoint: string, data: any): Promise<any> {
|
async putData(endpoint: string, data: any): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
const result = await this.executeRequest(endpoint, "PUT", data);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteData(endpoint: string): Promise<any> {
|
async deleteData(endpoint: string): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
const result = await this.executeRequest(endpoint, "DELETE");
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
host: string;
|
host: string;
|
||||||
|
|
@ -15,13 +15,15 @@ export interface QueryResult {
|
||||||
rows: any[];
|
rows: any[];
|
||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
fields?: any[];
|
fields?: any[];
|
||||||
|
affectedRows?: number; // MySQL/MariaDB용
|
||||||
|
length?: number; // 배열 형태로 반환되는 경우
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseConnector {
|
export interface DatabaseConnector {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
testConnection(): Promise<ConnectionTestResult>;
|
testConnection(): Promise<ConnectionTestResult>;
|
||||||
executeQuery(query: string): Promise<QueryResult>;
|
executeQuery(query: string, params?: any[]): Promise<QueryResult>; // params 추가
|
||||||
getTables(): Promise<TableInfo[]>;
|
getTables(): Promise<TableInfo[]>;
|
||||||
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
||||||
}
|
}
|
||||||
|
|
@ -895,13 +895,18 @@ export class BatchExternalDbService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회 (REST API는 executeRequest 사용)
|
||||||
const result = await connector.executeQuery(finalEndpoint, method);
|
let result;
|
||||||
|
if ((connector as any).executeRequest) {
|
||||||
|
result = await (connector as any).executeRequest(finalEndpoint, method);
|
||||||
|
} else {
|
||||||
|
result = await connector.executeQuery(finalEndpoint);
|
||||||
|
}
|
||||||
let data = result.rows;
|
let data = result.rows;
|
||||||
|
|
||||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||||
if (columns && columns.length > 0) {
|
if (columns && columns.length > 0) {
|
||||||
data = data.map((row) => {
|
data = data.map((row: any) => {
|
||||||
const filteredRow: any = {};
|
const filteredRow: any = {};
|
||||||
columns.forEach((col) => {
|
columns.forEach((col) => {
|
||||||
if (row.hasOwnProperty(col)) {
|
if (row.hasOwnProperty(col)) {
|
||||||
|
|
@ -1039,7 +1044,16 @@ export class BatchExternalDbService {
|
||||||
);
|
);
|
||||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
||||||
|
|
||||||
await connector.executeQuery(finalEndpoint, method, requestData);
|
// REST API는 executeRequest 사용
|
||||||
|
if ((connector as any).executeRequest) {
|
||||||
|
await (connector as any).executeRequest(
|
||||||
|
finalEndpoint,
|
||||||
|
method,
|
||||||
|
requestData
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await connector.executeQuery(finalEndpoint);
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`REST API 레코드 전송 실패:`, error);
|
console.error(`REST API 레코드 전송 실패:`, error);
|
||||||
|
|
@ -1104,7 +1118,12 @@ export class BatchExternalDbService {
|
||||||
);
|
);
|
||||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
||||||
|
|
||||||
await connector.executeQuery(endpoint, method, record);
|
// REST API는 executeRequest 사용
|
||||||
|
if ((connector as any).executeRequest) {
|
||||||
|
await (connector as any).executeRequest(endpoint, method, record);
|
||||||
|
} else {
|
||||||
|
await connector.executeQuery(endpoint);
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`REST API 레코드 전송 실패:`, error);
|
console.error(`REST API 레코드 전송 실패:`, error);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,332 @@
|
||||||
|
# 액션 노드 타겟 선택 시스템 개선 계획
|
||||||
|
|
||||||
|
## 📋 현재 문제점
|
||||||
|
|
||||||
|
### 1. 타겟 타입 구분 부재
|
||||||
|
- INSERT/UPDATE/DELETE/UPSERT 액션 노드가 타겟 테이블만 선택 가능
|
||||||
|
- 내부 DB인지, 외부 DB인지, REST API인지 구분 없음
|
||||||
|
- 실행 시 항상 내부 DB로만 동작
|
||||||
|
|
||||||
|
### 2. 외부 시스템 연동 불가
|
||||||
|
- 외부 DB에 데이터 저장 불가
|
||||||
|
- 외부 REST API 호출 불가
|
||||||
|
- 하이브리드 플로우 구성 불가 (내부 → 외부 데이터 전송)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 개선 목표
|
||||||
|
|
||||||
|
액션 노드에서 다음 3가지 타겟 타입을 선택할 수 있도록 개선:
|
||||||
|
|
||||||
|
### 1. 내부 데이터베이스 (Internal DB)
|
||||||
|
- 현재 시스템의 PostgreSQL
|
||||||
|
- 기존 동작 유지
|
||||||
|
|
||||||
|
### 2. 외부 데이터베이스 (External DB)
|
||||||
|
- 외부 커넥션 관리에서 설정한 DB
|
||||||
|
- PostgreSQL, MySQL, Oracle, MSSQL, MariaDB 지원
|
||||||
|
|
||||||
|
### 3. REST API
|
||||||
|
- 외부 REST API 호출
|
||||||
|
- HTTP 메서드: POST, PUT, PATCH, DELETE
|
||||||
|
- 인증: None, Basic, Bearer Token, API Key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 타입 정의 확장
|
||||||
|
|
||||||
|
### TargetType 추가
|
||||||
|
```typescript
|
||||||
|
export type TargetType = "internal" | "external" | "api";
|
||||||
|
|
||||||
|
export interface BaseActionNodeData {
|
||||||
|
displayName: string;
|
||||||
|
targetType: TargetType; // 🔥 새로 추가
|
||||||
|
|
||||||
|
// targetType === "internal"
|
||||||
|
targetTable?: string;
|
||||||
|
targetTableLabel?: string;
|
||||||
|
|
||||||
|
// targetType === "external"
|
||||||
|
externalConnectionId?: number;
|
||||||
|
externalConnectionName?: string;
|
||||||
|
externalDbType?: string;
|
||||||
|
externalTargetTable?: string;
|
||||||
|
externalTargetSchema?: string;
|
||||||
|
|
||||||
|
// targetType === "api"
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
apiAuthType?: "none" | "basic" | "bearer" | "apikey";
|
||||||
|
apiAuthConfig?: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
token?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader?: string;
|
||||||
|
};
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
apiBodyTemplate?: string; // JSON 템플릿
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI 설계
|
||||||
|
|
||||||
|
### 1. 타겟 타입 선택 (공통)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 타겟 타입 │
|
||||||
|
│ ○ 내부 데이터베이스 (기본) │
|
||||||
|
│ ○ 외부 데이터베이스 │
|
||||||
|
│ ○ REST API │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-A. 내부 DB 선택 시 (기존 UI 유지)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 테이블 선택: [검색 가능 Combobox] │
|
||||||
|
│ 필드 매핑: │
|
||||||
|
│ • source_field → target_field │
|
||||||
|
│ • ... │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-B. 외부 DB 선택 시
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 외부 커넥션: [🐘 PostgreSQL - 운영DB]│
|
||||||
|
│ 스키마: [public ▼] │
|
||||||
|
│ 테이블: [users ▼] │
|
||||||
|
│ 필드 매핑: │
|
||||||
|
│ • source_field → target_field │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-C. REST API 선택 시
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ API 엔드포인트: │
|
||||||
|
│ [https://api.example.com/users] │
|
||||||
|
│ │
|
||||||
|
│ HTTP 메서드: [POST ▼] │
|
||||||
|
│ │
|
||||||
|
│ 인증 타입: [Bearer Token ▼] │
|
||||||
|
│ Token: [••••••••••••••] │
|
||||||
|
│ │
|
||||||
|
│ 헤더 (선택): │
|
||||||
|
│ Content-Type: application/json │
|
||||||
|
│ + 헤더 추가 │
|
||||||
|
│ │
|
||||||
|
│ 바디 템플릿: │
|
||||||
|
│ { │
|
||||||
|
│ "name": "{{source.name}}", │
|
||||||
|
│ "email": "{{source.email}}" │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 구현 단계
|
||||||
|
|
||||||
|
### Phase 1: 타입 정의 및 기본 UI (1-2시간)
|
||||||
|
- [ ] `types/node-editor.ts`에 `TargetType` 추가
|
||||||
|
- [ ] `InsertActionNodeData` 등 인터페이스 확장
|
||||||
|
- [ ] 속성 패널에 타겟 타입 선택 라디오 버튼 추가
|
||||||
|
|
||||||
|
### Phase 2: 내부 DB 타입 (기존 유지)
|
||||||
|
- [ ] `targetType === "internal"` 처리
|
||||||
|
- [ ] 기존 로직 그대로 유지
|
||||||
|
- [ ] 기본값으로 설정
|
||||||
|
|
||||||
|
### Phase 3: 외부 DB 타입 (2-3시간)
|
||||||
|
- [ ] 외부 커넥션 선택 UI
|
||||||
|
- [ ] 외부 테이블/컬럼 로드 (기존 API 재사용)
|
||||||
|
- [ ] 백엔드: `nodeFlowExecutionService.ts`에 외부 DB 실행 로직 추가
|
||||||
|
- [ ] `DatabaseConnectorFactory` 활용
|
||||||
|
|
||||||
|
### Phase 4: REST API 타입 (3-4시간)
|
||||||
|
- [ ] API 엔드포인트 설정 UI
|
||||||
|
- [ ] HTTP 메서드 선택
|
||||||
|
- [ ] 인증 타입별 설정 UI
|
||||||
|
- [ ] 바디 템플릿 에디터 (변수 치환 지원)
|
||||||
|
- [ ] 백엔드: Axios를 사용한 API 호출 로직
|
||||||
|
- [ ] 응답 처리 및 에러 핸들링
|
||||||
|
|
||||||
|
### Phase 5: 노드 시각화 개선 (1시간)
|
||||||
|
- [ ] 노드에 타겟 타입 아이콘 표시
|
||||||
|
- 내부 DB: 💾
|
||||||
|
- 외부 DB: 🔌 + DB 타입 아이콘
|
||||||
|
- REST API: 🌐
|
||||||
|
- [ ] 노드 색상 구분
|
||||||
|
|
||||||
|
### Phase 6: 검증 및 테스트 (2시간)
|
||||||
|
- [ ] 타겟 타입별 필수 값 검증
|
||||||
|
- [ ] 연결 규칙 업데이트
|
||||||
|
- [ ] 통합 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 백엔드 실행 로직
|
||||||
|
|
||||||
|
### nodeFlowExecutionService.ts
|
||||||
|
```typescript
|
||||||
|
private static async executeInsertAction(
|
||||||
|
node: FlowNode,
|
||||||
|
inputData: any[],
|
||||||
|
context: ExecutionContext
|
||||||
|
): Promise<any[]> {
|
||||||
|
const { targetType } = node.data;
|
||||||
|
|
||||||
|
switch (targetType) {
|
||||||
|
case "internal":
|
||||||
|
return this.executeInternalInsert(node, inputData);
|
||||||
|
|
||||||
|
case "external":
|
||||||
|
return this.executeExternalInsert(node, inputData);
|
||||||
|
|
||||||
|
case "api":
|
||||||
|
return this.executeApiInsert(node, inputData);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`지원하지 않는 타겟 타입: ${targetType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 외부 DB INSERT
|
||||||
|
private static async executeExternalInsert(
|
||||||
|
node: FlowNode,
|
||||||
|
inputData: any[]
|
||||||
|
): Promise<any[]> {
|
||||||
|
const { externalConnectionId, externalTargetTable, fieldMappings } = node.data;
|
||||||
|
|
||||||
|
const connector = await DatabaseConnectorFactory.getConnector(
|
||||||
|
externalConnectionId!,
|
||||||
|
node.data.externalDbType!
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const row of inputData) {
|
||||||
|
const values = fieldMappings.map(m => row[m.sourceField]);
|
||||||
|
const columns = fieldMappings.map(m => m.targetField);
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(
|
||||||
|
`INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${...})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connector.disconnect();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 REST API INSERT (POST)
|
||||||
|
private static async executeApiInsert(
|
||||||
|
node: FlowNode,
|
||||||
|
inputData: any[]
|
||||||
|
): Promise<any[]> {
|
||||||
|
const {
|
||||||
|
apiEndpoint,
|
||||||
|
apiMethod,
|
||||||
|
apiAuthType,
|
||||||
|
apiAuthConfig,
|
||||||
|
apiHeaders,
|
||||||
|
apiBodyTemplate
|
||||||
|
} = node.data;
|
||||||
|
|
||||||
|
const axios = require("axios");
|
||||||
|
const headers = { ...apiHeaders };
|
||||||
|
|
||||||
|
// 인증 헤더 추가
|
||||||
|
if (apiAuthType === "bearer" && apiAuthConfig?.token) {
|
||||||
|
headers["Authorization"] = `Bearer ${apiAuthConfig.token}`;
|
||||||
|
} else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) {
|
||||||
|
headers[apiAuthConfig.apiKeyHeader || "X-API-Key"] = apiAuthConfig.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const row of inputData) {
|
||||||
|
// 템플릿 변수 치환
|
||||||
|
const body = this.replaceTemplateVariables(apiBodyTemplate, row);
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: apiMethod || "POST",
|
||||||
|
url: apiEndpoint,
|
||||||
|
headers,
|
||||||
|
data: JSON.parse(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 우선순위
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
1. **Phase 1**: 타입 정의 및 기본 UI
|
||||||
|
2. **Phase 2**: 내부 DB 타입 (기존 유지)
|
||||||
|
3. **Phase 3**: 외부 DB 타입
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
4. **Phase 4**: REST API 타입
|
||||||
|
5. **Phase 5**: 노드 시각화
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
6. **Phase 6**: 고급 기능 (재시도, 배치 처리 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 효과
|
||||||
|
|
||||||
|
### 1. 유연성 증가
|
||||||
|
- 다양한 시스템 간 데이터 연동 가능
|
||||||
|
- 하이브리드 플로우 구성 (내부 → 외부 → API)
|
||||||
|
|
||||||
|
### 2. 사용 사례 확장
|
||||||
|
```
|
||||||
|
[사례 1] 내부 DB → 외부 DB 동기화
|
||||||
|
TableSource(내부)
|
||||||
|
→ DataTransform
|
||||||
|
→ INSERT(외부 DB)
|
||||||
|
|
||||||
|
[사례 2] 내부 DB → REST API 전송
|
||||||
|
TableSource(내부)
|
||||||
|
→ DataTransform
|
||||||
|
→ INSERT(REST API)
|
||||||
|
|
||||||
|
[사례 3] 복합 플로우
|
||||||
|
TableSource(내부)
|
||||||
|
→ INSERT(외부 DB)
|
||||||
|
→ INSERT(REST API - 알림)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 기존 기능과의 호환
|
||||||
|
- 기본값: `targetType = "internal"`
|
||||||
|
- 기존 플로우 마이그레이션 불필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 1. 보안
|
||||||
|
- API 인증 정보 암호화 저장
|
||||||
|
- 민감 데이터 로깅 방지
|
||||||
|
|
||||||
|
### 2. 에러 핸들링
|
||||||
|
- 외부 시스템 타임아웃 처리
|
||||||
|
- 재시도 로직 (선택적)
|
||||||
|
- 부분 실패 처리 (이미 구현됨)
|
||||||
|
|
||||||
|
### 3. 성능
|
||||||
|
- 외부 DB 연결 풀 관리 (이미 구현됨)
|
||||||
|
- REST API Rate Limiting 고려
|
||||||
|
|
||||||
|
|
@ -111,13 +111,31 @@ function FlowEditorInner() {
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔥 노드 타입별 기본 데이터 설정
|
||||||
|
const defaultData: any = {
|
||||||
|
displayName: `새 ${type} 노드`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 액션 노드의 경우 targetType 기본값 설정
|
||||||
|
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||||
|
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||||
|
defaultData.fieldMappings = [];
|
||||||
|
defaultData.options = {};
|
||||||
|
|
||||||
|
if (type === "updateAction" || type === "deleteAction") {
|
||||||
|
defaultData.whereConditions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "upsertAction") {
|
||||||
|
defaultData.conflictKeys = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newNode: any = {
|
const newNode: any = {
|
||||||
id: `node_${Date.now()}`,
|
id: `node_${Date.now()}`,
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
data: {
|
data: defaultData,
|
||||||
displayName: `새 ${type} 노드`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
addNode(newNode);
|
addNode(newNode);
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { 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 { DeleteActionNodeData } from "@/types/node-editor";
|
||||||
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
interface DeleteActionPropertiesProps {
|
interface DeleteActionPropertiesProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|
@ -29,18 +35,157 @@ const OPERATORS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
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 [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||||
setTargetTable(data.targetTable);
|
setTargetTable(data.targetTable);
|
||||||
setWhereConditions(data.whereConditions || []);
|
setWhereConditions(data.whereConditions || []);
|
||||||
}, [data]);
|
}, [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 = () => {
|
const handleAddCondition = () => {
|
||||||
setWhereConditions([
|
setWhereConditions([
|
||||||
...whereConditions,
|
...whereConditions,
|
||||||
|
|
@ -103,17 +248,385 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🔥 타겟 타입 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="targetTable" className="text-xs">
|
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||||
타겟 테이블
|
<div className="grid grid-cols-3 gap-2">
|
||||||
</Label>
|
<button
|
||||||
<Input
|
type="button"
|
||||||
id="targetTable"
|
onClick={() => handleTargetTypeChange("internal")}
|
||||||
value={targetTable}
|
className={cn(
|
||||||
onChange={(e) => setTargetTable(e.target.value)}
|
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||||
className="mt-1"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||||
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
interface UpdateActionPropertiesProps {
|
interface UpdateActionPropertiesProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|
@ -54,7 +56,10 @@ const OPERATORS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) {
|
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 [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||||
const [targetTable, setTargetTable] = useState(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 [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||||
|
|
||||||
// 테이블 관련 상태
|
// 내부 DB 테이블 관련 상태
|
||||||
const [tables, setTables] = useState<TableOption[]>([]);
|
const [tables, setTables] = useState<TableOption[]>([]);
|
||||||
const [tablesLoading, setTablesLoading] = useState(false);
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
const [tablesOpen, setTablesOpen] = 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 }>>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || data.targetTable);
|
setDisplayName(data.displayName || data.targetTable);
|
||||||
|
|
@ -85,17 +110,40 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// 테이블 목록 로딩
|
// 내부 DB 테이블 목록 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTables();
|
if (targetType === "internal") {
|
||||||
}, []);
|
loadTables();
|
||||||
|
}
|
||||||
|
}, [targetType]);
|
||||||
|
|
||||||
// 타겟 테이블 변경 시 컬럼 로딩
|
// 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetTable) {
|
if (targetType === "internal" && targetTable) {
|
||||||
loadColumns(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(() => {
|
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) => {
|
const loadColumns = async (tableName: string) => {
|
||||||
try {
|
try {
|
||||||
setColumnsLoading(true);
|
setColumnsLoading(true);
|
||||||
|
|
@ -302,6 +398,34 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
setFieldMappings(newMappings);
|
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 = () => {
|
const handleAddCondition = () => {
|
||||||
setWhereConditions([
|
setWhereConditions([
|
||||||
...whereConditions,
|
...whereConditions,
|
||||||
|
|
@ -391,58 +515,433 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타겟 테이블 Combobox */}
|
{/* 🔥 타겟 타입 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">타겟 테이블</Label>
|
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<PopoverTrigger asChild>
|
{/* 내부 데이터베이스 */}
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
onClick={() => handleTargetTypeChange("internal")}
|
||||||
role="combobox"
|
className={cn(
|
||||||
aria-expanded={tablesOpen}
|
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||||
className="mt-1 w-full justify-between"
|
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||||
disabled={tablesLoading}
|
)}
|
||||||
|
>
|
||||||
|
<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 ? (
|
내부 DB
|
||||||
<span className="text-muted-foreground">로딩 중...</span>
|
</span>
|
||||||
) : targetTable ? (
|
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||||
<span className="truncate">{selectedTableLabel}</span>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
{/* 외부 데이터베이스 */}
|
||||||
|
<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>
|
외부 DB
|
||||||
</PopoverTrigger>
|
</span>
|
||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||||
<Command>
|
</button>
|
||||||
<CommandInput placeholder="테이블 검색..." />
|
|
||||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
{/* REST API */}
|
||||||
<CommandList>
|
<button
|
||||||
<CommandGroup>
|
onClick={() => handleTargetTypeChange("api")}
|
||||||
{tables.map((table) => (
|
className={cn(
|
||||||
<CommandItem
|
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||||
key={table.tableName}
|
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
)}
|
||||||
onSelect={() => handleTableSelect(table.tableName)}
|
>
|
||||||
>
|
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||||
<Check
|
<span
|
||||||
className={cn(
|
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||||
"mr-2 h-4 w-4",
|
>
|
||||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
REST API
|
||||||
)}
|
</span>
|
||||||
/>
|
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||||
<div className="flex flex-col">
|
</button>
|
||||||
<span className="font-medium">{table.label || table.displayName}</span>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -589,129 +1088,133 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 매핑 */}
|
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
|
||||||
<div>
|
{targetType !== "api" && (
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div>
|
||||||
<h3 className="text-sm font-semibold">업데이트 필드</h3>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
<h3 className="text-sm font-semibold">업데이트 필드</h3>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
||||||
매핑 추가
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
</Button>
|
매핑 추가
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
{!targetTable && !columnsLoading && (
|
|
||||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
|
||||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
{!targetTable && !columnsLoading && (
|
||||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||||
❌ 컬럼 정보를 불러올 수 없습니다
|
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targetTable && targetColumns.length > 0 && (
|
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||||
<>
|
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||||
{fieldMappings.length > 0 ? (
|
❌ 컬럼 정보를 불러올 수 없습니다
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{targetTable && targetColumns.length > 0 && (
|
||||||
{/* 소스 필드 드롭다운 */}
|
<>
|
||||||
<div>
|
{fieldMappings.length > 0 ? (
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
{fieldMappings.map((mapping, index) => (
|
||||||
value={mapping.sourceField || ""}
|
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
<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">
|
<Trash2 className="h-3 w-3" />
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
</Button>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
|
||||||
{sourceFields.length === 0 ? (
|
<div className="space-y-2">
|
||||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
{/* 소스 필드 드롭다운 */}
|
||||||
) : (
|
<div>
|
||||||
sourceFields.map((field) => (
|
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||||
{field.label && field.label !== field.name && (
|
<span className="text-muted-foreground">
|
||||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
{col.dataType}
|
||||||
)}
|
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
</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>
|
||||||
|
<Input
|
||||||
{/* 타겟 필드 드롭다운 */}
|
value={mapping.staticValue || ""}
|
||||||
<div>
|
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
placeholder="소스 필드 대신 고정 값 사용"
|
||||||
<Select
|
className="mt-1 h-8 text-xs"
|
||||||
value={mapping.targetField}
|
/>
|
||||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||||
>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
업데이트할 필드를 추가하세요
|
||||||
업데이트할 필드를 추가하세요
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 옵션 */}
|
{/* 옵션 */}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -17,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||||
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
interface UpsertActionPropertiesProps {
|
interface UpsertActionPropertiesProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|
@ -39,7 +41,10 @@ interface ColumnInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
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 [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||||
const [targetTable, setTargetTable] = useState(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 [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||||
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
|
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 [tables, setTables] = useState<TableOption[]>([]);
|
||||||
const [tablesLoading, setTablesLoading] = useState(false);
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
|
@ -72,17 +97,40 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// 테이블 목록 로딩
|
// 🔥 내부 DB 테이블 목록 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTables();
|
if (targetType === "internal") {
|
||||||
}, []);
|
loadTables();
|
||||||
|
}
|
||||||
|
}, [targetType]);
|
||||||
|
|
||||||
// 타겟 테이블 변경 시 컬럼 로딩
|
// 🔥 내부 DB 타겟 테이블 변경 시 컬럼 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetTable) {
|
if (targetType === "internal" && targetTable) {
|
||||||
loadColumns(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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -162,6 +210,66 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
setSourceFields(uniqueFields);
|
setSourceFields(uniqueFields);
|
||||||
}, [nodeId, nodes, edges]);
|
}, [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 () => {
|
const loadTables = async () => {
|
||||||
try {
|
try {
|
||||||
setTablesLoading(true);
|
setTablesLoading(true);
|
||||||
|
|
@ -355,58 +463,425 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타겟 테이블 Combobox */}
|
{/* 🔥 타겟 타입 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">타겟 테이블</Label>
|
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<PopoverTrigger asChild>
|
<button
|
||||||
<Button
|
type="button"
|
||||||
variant="outline"
|
onClick={() => handleTargetTypeChange("internal")}
|
||||||
role="combobox"
|
className={cn(
|
||||||
aria-expanded={tablesOpen}
|
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||||
className="mt-1 w-full justify-between"
|
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||||
disabled={tablesLoading}
|
)}
|
||||||
|
>
|
||||||
|
<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 ? (
|
내부 DB
|
||||||
<span className="text-muted-foreground">로딩 중...</span>
|
</span>
|
||||||
) : targetTable ? (
|
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||||
<span className="truncate">{selectedTableLabel}</span>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
<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>
|
외부 DB
|
||||||
</PopoverTrigger>
|
</span>
|
||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||||
<Command>
|
</button>
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
|
||||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
<button
|
||||||
<CommandList>
|
type="button"
|
||||||
<CommandGroup>
|
onClick={() => handleTargetTypeChange("api")}
|
||||||
{tables.map((table) => (
|
className={cn(
|
||||||
<CommandItem
|
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||||
key={table.tableName}
|
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
)}
|
||||||
onSelect={() => handleTableSelect(table.tableName)}
|
>
|
||||||
>
|
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||||
<Check
|
<span
|
||||||
className={cn(
|
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||||
"mr-2 h-4 w-4",
|
>
|
||||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
REST API
|
||||||
)}
|
</span>
|
||||||
/>
|
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||||
<div className="flex flex-col">
|
</button>
|
||||||
<span className="font-medium">{table.label || table.displayName}</span>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
# REST API UI 구현 패턴
|
||||||
|
|
||||||
|
UPDATE, DELETE, UPSERT 노드에 적용할 REST API UI 패턴입니다.
|
||||||
|
|
||||||
|
## 1. Import 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Database, Globe, Link2 } from "lucide-react";
|
||||||
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 상태 변수 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 타겟 타입 상태
|
||||||
|
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||||
|
|
||||||
|
// 외부 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" | "DELETE">(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 || "");
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 타겟 타입 선택 UI (기본 정보 섹션 내부)
|
||||||
|
|
||||||
|
기존 "타겟 테이블" 입력 필드 위에 추가:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 🔥 타겟 타입 선택 */}
|
||||||
|
<div>
|
||||||
|
<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")}>
|
||||||
|
내부 DB
|
||||||
|
</span>
|
||||||
|
{targetType === "internal" && (
|
||||||
|
<Check className="absolute right-2 top-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")}>
|
||||||
|
외부 DB
|
||||||
|
</span>
|
||||||
|
{targetType === "external" && (
|
||||||
|
<Check className="absolute right-2 top-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 right-2 top-2 h-4 w-4 text-purple-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. REST API 설정 UI (타겟 타입이 "api"일 때)
|
||||||
|
|
||||||
|
기존 테이블 선택 UI를 조건부로 변경하고, REST API UI 추가:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 내부 DB 설정 */}
|
||||||
|
{targetType === "internal" && (
|
||||||
|
<div>
|
||||||
|
{/* 기존 타겟 테이블 Combobox */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 외부 DB 설정 (INSERT 노드 참고) */}
|
||||||
|
{targetType === "external" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 외부 커넥션 선택, 테이블 선택, 컬럼 표시 */}
|
||||||
|
</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 메서드 (UPDATE: PUT/PATCH, DELETE: DELETE만) */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
|
||||||
|
<Select
|
||||||
|
value={apiMethod}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setApiMethod(value);
|
||||||
|
updateNode(nodeId, { apiMethod: value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* UPDATE 노드: PUT, PATCH */}
|
||||||
|
<SelectItem value="PUT">PUT</SelectItem>
|
||||||
|
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||||
|
{/* DELETE 노드: DELETE만 */}
|
||||||
|
{/* <SelectItem value="DELETE">DELETE</SelectItem> */}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 방식, 인증 정보, 커스텀 헤더 (INSERT와 동일) */}
|
||||||
|
|
||||||
|
{/* 요청 바디 템플릿 (DELETE는 제외) */}
|
||||||
|
<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}`}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 필드 매핑 섹션 조건부 렌더링
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
|
||||||
|
{targetType !== "api" && (
|
||||||
|
<div>
|
||||||
|
{/* 기존 필드 매핑 UI */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. handleTargetTypeChange 함수
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 노드별 차이점
|
||||||
|
|
||||||
|
### UPDATE 노드
|
||||||
|
- HTTP 메서드: `PUT`, `PATCH`
|
||||||
|
- WHERE 조건 필요
|
||||||
|
- 요청 바디 템플릿 필요
|
||||||
|
|
||||||
|
### DELETE 노드
|
||||||
|
- HTTP 메서드: `DELETE` 만
|
||||||
|
- WHERE 조건 필요
|
||||||
|
- 요청 바디 템플릿 **불필요** (쿼리 파라미터로 ID 전달)
|
||||||
|
|
||||||
|
### UPSERT 노드
|
||||||
|
- HTTP 메서드: `POST`, `PUT`, `PATCH`
|
||||||
|
- Conflict Keys 필요
|
||||||
|
- 요청 바디 템플릿 필요
|
||||||
|
|
||||||
|
|
@ -22,6 +22,24 @@ export type NodeType =
|
||||||
| "comment" // 주석
|
| "comment" // 주석
|
||||||
| "log"; // 로그
|
| "log"; // 로그
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 타겟 타입 (액션 노드용)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TargetType = "internal" | "external" | "api";
|
||||||
|
|
||||||
|
// API 인증 타입
|
||||||
|
export type ApiAuthType = "none" | "basic" | "bearer" | "apikey";
|
||||||
|
|
||||||
|
// API 인증 설정
|
||||||
|
export interface ApiAuthConfig {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
token?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader?: string; // 기본값: "X-API-Key"
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 필드 정의
|
// 필드 정의
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -149,13 +167,37 @@ export interface DataTransformNodeData {
|
||||||
|
|
||||||
// INSERT 액션 노드
|
// INSERT 액션 노드
|
||||||
export interface InsertActionNodeData {
|
export interface InsertActionNodeData {
|
||||||
targetConnection: number;
|
displayName?: string;
|
||||||
targetTable: string;
|
|
||||||
|
// 🔥 타겟 타입 (새로 추가)
|
||||||
|
targetType: TargetType; // "internal" | "external" | "api"
|
||||||
|
|
||||||
|
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||||
|
targetConnection?: number;
|
||||||
|
targetTable?: string;
|
||||||
|
targetTableLabel?: string;
|
||||||
|
|
||||||
|
// === 외부 DB 타겟 (targetType === "external") ===
|
||||||
|
externalConnectionId?: number;
|
||||||
|
externalConnectionName?: string;
|
||||||
|
externalDbType?: string;
|
||||||
|
externalTargetTable?: string;
|
||||||
|
externalTargetSchema?: string;
|
||||||
|
|
||||||
|
// === REST API 타겟 (targetType === "api") ===
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
apiAuthType?: ApiAuthType;
|
||||||
|
apiAuthConfig?: ApiAuthConfig;
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
apiBodyTemplate?: string; // JSON 템플릿
|
||||||
|
|
||||||
|
// === 공통 필드 ===
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
sourceField: string | null;
|
sourceField: string | null;
|
||||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
sourceFieldLabel?: string;
|
||||||
targetField: string;
|
targetField: string;
|
||||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
targetFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
}>;
|
}>;
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -163,39 +205,85 @@ export interface InsertActionNodeData {
|
||||||
ignoreErrors?: boolean;
|
ignoreErrors?: boolean;
|
||||||
ignoreDuplicates?: boolean;
|
ignoreDuplicates?: boolean;
|
||||||
};
|
};
|
||||||
displayName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATE 액션 노드
|
// UPDATE 액션 노드
|
||||||
export interface UpdateActionNodeData {
|
export interface UpdateActionNodeData {
|
||||||
targetConnection: number;
|
displayName?: string;
|
||||||
targetTable: string;
|
|
||||||
|
// 🔥 타겟 타입 (새로 추가)
|
||||||
|
targetType: TargetType; // "internal" | "external" | "api"
|
||||||
|
|
||||||
|
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||||
|
targetConnection?: number;
|
||||||
|
targetTable?: string;
|
||||||
|
targetTableLabel?: string;
|
||||||
|
|
||||||
|
// === 외부 DB 타겟 (targetType === "external") ===
|
||||||
|
externalConnectionId?: number;
|
||||||
|
externalConnectionName?: string;
|
||||||
|
externalDbType?: string;
|
||||||
|
externalTargetTable?: string;
|
||||||
|
externalTargetSchema?: string;
|
||||||
|
|
||||||
|
// === REST API 타겟 (targetType === "api") ===
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
apiAuthType?: ApiAuthType;
|
||||||
|
apiAuthConfig?: ApiAuthConfig;
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
apiBodyTemplate?: string; // JSON 템플릿
|
||||||
|
|
||||||
|
// === 공통 필드 ===
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
sourceField: string | null;
|
sourceField: string | null;
|
||||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
sourceFieldLabel?: string;
|
||||||
targetField: string;
|
targetField: string;
|
||||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
targetFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
}>;
|
}>;
|
||||||
whereConditions: Array<{
|
whereConditions: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
fieldLabel?: string; // 필드 라벨
|
fieldLabel?: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
sourceField?: string;
|
sourceField?: string;
|
||||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
sourceFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
}>;
|
}>;
|
||||||
options: {
|
options: {
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
ignoreErrors?: boolean;
|
ignoreErrors?: boolean;
|
||||||
};
|
};
|
||||||
displayName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE 액션 노드
|
// DELETE 액션 노드
|
||||||
export interface DeleteActionNodeData {
|
export interface DeleteActionNodeData {
|
||||||
targetConnection: number;
|
displayName?: string;
|
||||||
targetTable: string;
|
|
||||||
|
// 🔥 타겟 타입 (새로 추가)
|
||||||
|
targetType: TargetType; // "internal" | "external" | "api"
|
||||||
|
|
||||||
|
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||||
|
targetConnection?: number;
|
||||||
|
targetTable?: string;
|
||||||
|
targetTableLabel?: string;
|
||||||
|
|
||||||
|
// === 외부 DB 타겟 (targetType === "external") ===
|
||||||
|
externalConnectionId?: number;
|
||||||
|
externalConnectionName?: string;
|
||||||
|
externalDbType?: string;
|
||||||
|
externalTargetTable?: string;
|
||||||
|
externalTargetSchema?: string;
|
||||||
|
|
||||||
|
// === REST API 타겟 (targetType === "api") ===
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
apiAuthType?: ApiAuthType;
|
||||||
|
apiAuthConfig?: ApiAuthConfig;
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
apiBodyTemplate?: string;
|
||||||
|
|
||||||
|
// === 공통 필드 ===
|
||||||
whereConditions: Array<{
|
whereConditions: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
|
@ -205,27 +293,49 @@ export interface DeleteActionNodeData {
|
||||||
options: {
|
options: {
|
||||||
requireConfirmation?: boolean;
|
requireConfirmation?: boolean;
|
||||||
};
|
};
|
||||||
displayName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPSERT 액션 노드
|
// UPSERT 액션 노드
|
||||||
export interface UpsertActionNodeData {
|
export interface UpsertActionNodeData {
|
||||||
targetConnection: number;
|
displayName?: string;
|
||||||
targetTable: string;
|
|
||||||
|
// 🔥 타겟 타입 (새로 추가)
|
||||||
|
targetType: TargetType; // "internal" | "external" | "api"
|
||||||
|
|
||||||
|
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||||
|
targetConnection?: number;
|
||||||
|
targetTable?: string;
|
||||||
|
targetTableLabel?: string;
|
||||||
|
|
||||||
|
// === 외부 DB 타겟 (targetType === "external") ===
|
||||||
|
externalConnectionId?: number;
|
||||||
|
externalConnectionName?: string;
|
||||||
|
externalDbType?: string;
|
||||||
|
externalTargetTable?: string;
|
||||||
|
externalTargetSchema?: string;
|
||||||
|
|
||||||
|
// === REST API 타겟 (targetType === "api") ===
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
apiAuthType?: ApiAuthType;
|
||||||
|
apiAuthConfig?: ApiAuthConfig;
|
||||||
|
apiHeaders?: Record<string, string>;
|
||||||
|
apiBodyTemplate?: string;
|
||||||
|
|
||||||
|
// === 공통 필드 ===
|
||||||
conflictKeys: string[]; // ON CONFLICT 키
|
conflictKeys: string[]; // ON CONFLICT 키
|
||||||
conflictKeyLabels?: string[]; // 충돌 키 라벨
|
conflictKeyLabels?: string[]; // 충돌 키 라벨
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
sourceField: string | null;
|
sourceField: string | null;
|
||||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
sourceFieldLabel?: string;
|
||||||
targetField: string;
|
targetField: string;
|
||||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
targetFieldLabel?: string;
|
||||||
staticValue?: any;
|
staticValue?: any;
|
||||||
}>;
|
}>;
|
||||||
options?: {
|
options?: {
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
updateOnConflict?: boolean;
|
updateOnConflict?: boolean;
|
||||||
};
|
};
|
||||||
displayName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST API 소스 노드
|
// REST API 소스 노드
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue