- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
TestTube,
|
|
Play,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
AlertCircle,
|
|
Copy,
|
|
RefreshCw,
|
|
Zap,
|
|
Code,
|
|
Network,
|
|
Timer,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
|
|
// 타입 import
|
|
import {
|
|
ExternalCallTestPanelProps,
|
|
ApiTestResult,
|
|
ExternalCallContext,
|
|
} from "@/types/external-call/ExternalCallTypes";
|
|
import { ExternalCallAPI } from "@/lib/api/externalCall";
|
|
|
|
/**
|
|
* 🧪 API 테스트 전용 컴포넌트
|
|
*
|
|
* REST API 설정을 실제로 테스트하고 결과를 표시
|
|
* 템플릿 변수 치환, 응답 분석, 오류 진단 등 제공
|
|
*/
|
|
const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
|
|
settings,
|
|
context,
|
|
onTestResult,
|
|
disabled = false,
|
|
}) => {
|
|
// 상태 관리
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [testResult, setTestResult] = useState<ApiTestResult | null>(null);
|
|
const [activeTab, setActiveTab] = useState<string>("request");
|
|
const [processedTemplate, setProcessedTemplate] = useState<string>("");
|
|
const [testContext, setTestContext] = useState<ExternalCallContext>(() => ({
|
|
relationshipId: context?.relationshipId || "test-relationship",
|
|
diagramId: context?.diagramId || "test-diagram",
|
|
userId: context?.userId || "test-user",
|
|
executionId: context?.executionId || `test-${Date.now()}`,
|
|
sourceData: context?.sourceData || {
|
|
id: 1,
|
|
name: "테스트 데이터",
|
|
value: 100,
|
|
status: "active",
|
|
},
|
|
targetData: context?.targetData,
|
|
timestamp: context?.timestamp || new Date().toISOString(),
|
|
metadata: context?.metadata,
|
|
}));
|
|
|
|
// 템플릿 변수 치환 함수
|
|
const processTemplate = useCallback((template: string, context: ExternalCallContext): string => {
|
|
let processed = template;
|
|
|
|
// 각 템플릿 변수를 실제 값으로 치환
|
|
const replacements = {
|
|
"{{sourceData}}": JSON.stringify(context.sourceData, null, 2),
|
|
"{{targetData}}": context.targetData ? JSON.stringify(context.targetData, null, 2) : "null",
|
|
"{{timestamp}}": context.timestamp,
|
|
"{{relationshipId}}": context.relationshipId,
|
|
"{{diagramId}}": context.diagramId,
|
|
"{{userId}}": context.userId,
|
|
"{{executionId}}": context.executionId,
|
|
};
|
|
|
|
Object.entries(replacements).forEach(([variable, value]) => {
|
|
processed = processed.replace(new RegExp(variable.replace(/[{}]/g, "\\$&"), "g"), value);
|
|
});
|
|
|
|
return processed;
|
|
}, []);
|
|
|
|
// 템플릿 처리 (설정이나 컨텍스트 변경 시)
|
|
useEffect(() => {
|
|
if (settings.bodyTemplate) {
|
|
const processed = processTemplate(settings.bodyTemplate, testContext);
|
|
setProcessedTemplate(processed);
|
|
}
|
|
}, [settings.bodyTemplate, testContext, processTemplate]);
|
|
|
|
// API 테스트 실행
|
|
const handleRunTest = useCallback(async () => {
|
|
if (!settings.apiUrl) {
|
|
toast.error("API URL을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setTestResult(null);
|
|
|
|
try {
|
|
// 테스트 요청 데이터 구성 (백엔드 형식에 맞춤)
|
|
const testRequest = {
|
|
settings: {
|
|
callType: "rest-api" as const,
|
|
apiType: "generic" as const,
|
|
url: settings.apiUrl,
|
|
method: settings.httpMethod,
|
|
headers: settings.headers,
|
|
body: processedTemplate,
|
|
authentication: settings.authentication, // 인증 정보 추가
|
|
timeout: settings.timeout,
|
|
retryCount: settings.retryCount,
|
|
},
|
|
templateData: testContext,
|
|
};
|
|
|
|
// API 호출
|
|
const response = await ExternalCallAPI.testExternalCall(testRequest);
|
|
|
|
if (response.success && response.result) {
|
|
// 백엔드 응답을 ApiTestResult 형태로 변환
|
|
const apiTestResult: ApiTestResult = {
|
|
success: response.result.success,
|
|
statusCode: response.result.statusCode,
|
|
responseTime: response.result.executionTime || 0,
|
|
response: response.result.response,
|
|
error: response.result.error,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
setTestResult(apiTestResult);
|
|
onTestResult(apiTestResult);
|
|
|
|
if (apiTestResult.success) {
|
|
toast.success("API 테스트가 성공했습니다!");
|
|
setActiveTab("response");
|
|
} else {
|
|
showErrorToast("API 호출이 실패했습니다", null, { guidance: "URL과 요청 설정을 확인해 주세요." });
|
|
setActiveTab("response");
|
|
}
|
|
} else {
|
|
const errorResult: ApiTestResult = {
|
|
success: false,
|
|
responseTime: 0,
|
|
error: response.error || "알 수 없는 오류가 발생했습니다.",
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
setTestResult(errorResult);
|
|
onTestResult(errorResult);
|
|
showErrorToast("API 테스트 실행에 실패했습니다", response.error, { guidance: "URL과 요청 설정을 확인해 주세요." });
|
|
}
|
|
} catch (error) {
|
|
const errorResult: ApiTestResult = {
|
|
success: false,
|
|
responseTime: 0,
|
|
error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.",
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
setTestResult(errorResult);
|
|
onTestResult(errorResult);
|
|
showErrorToast("API 테스트 실행에 실패했습니다", error, { guidance: "네트워크 연결과 URL을 확인해 주세요." });
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [settings, processedTemplate, testContext, onTestResult]);
|
|
|
|
// 테스트 데이터 복사
|
|
const handleCopyToClipboard = useCallback(async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
toast.success("클립보드에 복사되었습니다.");
|
|
} catch (error) {
|
|
showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
|
|
}
|
|
}, []);
|
|
|
|
// 테스트 컨텍스트 리셋
|
|
const handleResetContext = useCallback(() => {
|
|
setTestContext({
|
|
relationshipId: "test-relationship",
|
|
diagramId: "test-diagram",
|
|
userId: "test-user",
|
|
executionId: `test-${Date.now()}`,
|
|
sourceData: {
|
|
id: 1,
|
|
name: "테스트 데이터",
|
|
value: 100,
|
|
status: "active",
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 테스트 실행 헤더 */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TestTube className="h-5 w-5 text-blue-500" />
|
|
<CardTitle className="text-lg">API 테스트</CardTitle>
|
|
{testResult && (
|
|
<Badge variant={testResult.success ? "default" : "destructive"}>
|
|
{testResult.success ? "성공" : "실패"}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleResetContext} disabled={disabled || isLoading}>
|
|
<RefreshCw className="mr-1 h-4 w-4" />
|
|
리셋
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleRunTest}
|
|
disabled={disabled || isLoading || !settings.apiUrl}
|
|
className="min-w-[100px]"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
|
테스트 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
테스트 실행
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* 테스트 결과 요약 */}
|
|
{testResult && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="grid grid-cols-4 gap-4 text-center">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-center gap-1">
|
|
{testResult.success ? (
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 text-red-500" />
|
|
)}
|
|
<span className="text-sm font-medium">상태</span>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs">{testResult.success ? "성공" : "실패"}</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Network className="h-4 w-4 text-blue-500" />
|
|
<span className="text-sm font-medium">상태 코드</span>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs">{testResult.statusCode || "N/A"}</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Timer className="h-4 w-4 text-orange-500" />
|
|
<span className="text-sm font-medium">응답 시간</span>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs">{testResult.responseTime}ms</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Clock className="h-4 w-4 text-purple-500" />
|
|
<span className="text-sm font-medium">실행 시간</span>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs">
|
|
{new Date(testResult.timestamp).toLocaleTimeString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 상세 정보 탭 */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-3">
|
|
<TabsTrigger value="request" className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4" />
|
|
요청 정보
|
|
</TabsTrigger>
|
|
<TabsTrigger value="response" className="flex items-center gap-2">
|
|
<Code className="h-4 w-4" />
|
|
응답 정보
|
|
</TabsTrigger>
|
|
<TabsTrigger value="context" className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
테스트 데이터
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 요청 정보 탭 */}
|
|
<TabsContent value="request" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">요청 정보</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleCopyToClipboard(
|
|
JSON.stringify(
|
|
{
|
|
url: settings.apiUrl,
|
|
method: settings.httpMethod,
|
|
headers: settings.headers,
|
|
body: processedTemplate,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<Copy className="mr-1 h-4 w-4" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* URL과 메서드 */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="col-span-1">
|
|
<Label className="text-muted-foreground text-xs">HTTP 메서드</Label>
|
|
<Badge variant="outline" className="mt-1">
|
|
{settings.httpMethod}
|
|
</Badge>
|
|
</div>
|
|
<div className="col-span-3">
|
|
<Label className="text-muted-foreground text-xs">URL</Label>
|
|
<div className="bg-muted mt-1 rounded p-2 font-mono text-sm break-all">{settings.apiUrl}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 헤더 */}
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">헤더</Label>
|
|
<ScrollArea className="mt-1 h-32 w-full rounded border">
|
|
<div className="p-3">
|
|
<pre className="text-xs">{JSON.stringify(settings.headers, null, 2)}</pre>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* 요청 바디 (POST/PUT/PATCH인 경우) */}
|
|
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">요청 바디 (템플릿 변수 치환됨)</Label>
|
|
<ScrollArea className="mt-1 h-40 w-full rounded border">
|
|
<div className="p-3">
|
|
<pre className="text-xs whitespace-pre-wrap">{processedTemplate}</pre>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 응답 정보 탭 */}
|
|
<TabsContent value="response" className="space-y-4">
|
|
{testResult ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">응답 정보</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleCopyToClipboard(JSON.stringify(testResult, null, 2))}
|
|
>
|
|
<Copy className="mr-1 h-4 w-4" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{testResult.success ? (
|
|
<>
|
|
{/* 상태 코드 */}
|
|
{testResult.statusCode && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">상태 코드</Label>
|
|
<div className="mt-1 rounded border bg-green-50 p-2">
|
|
<span className="font-mono text-sm text-green-700">{testResult.statusCode}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 응답 시간 */}
|
|
{testResult.responseTime !== undefined && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">응답 시간</Label>
|
|
<div className="mt-1 rounded border bg-accent p-2">
|
|
<span className="font-mono text-sm text-blue-700">{testResult.responseTime}ms</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 응답 데이터 */}
|
|
{testResult.response && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">응답 데이터</Label>
|
|
<ScrollArea className="mt-1 h-40 w-full rounded border">
|
|
<div className="p-3">
|
|
<pre className="text-xs whitespace-pre-wrap">{testResult.response}</pre>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Alert variant="destructive">
|
|
<XCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<div className="space-y-2">
|
|
<div className="font-medium">오류 발생</div>
|
|
<div className="text-sm">{testResult.error}</div>
|
|
{testResult.statusCode && <div className="text-sm">상태 코드: {testResult.statusCode}</div>}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-muted-foreground py-8 text-center">
|
|
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
|
<p>테스트를 실행하면 응답 정보가 여기에 표시됩니다.</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 테스트 데이터 탭 */}
|
|
<TabsContent value="context" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">테스트 컨텍스트</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleCopyToClipboard(JSON.stringify(testContext, null, 2))}
|
|
>
|
|
<Copy className="mr-1 h-4 w-4" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="text-muted-foreground text-sm">템플릿 변수 치환에 사용되는 테스트 데이터입니다.</div>
|
|
|
|
<ScrollArea className="h-60 w-full rounded border">
|
|
<div className="p-3">
|
|
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(testContext, null, 2)}</pre>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-sm">
|
|
실제 실행 시에는 관계의 실제 데이터가 사용됩니다. 이 데이터는 테스트 목적으로만 사용됩니다.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExternalCallTestPanel;
|