460 lines
17 KiB
TypeScript
460 lines
17 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 데이터 변환 노드 속성 편집 (개선 버전)
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import type { DataTransformNodeData } from "@/types/node-editor";
|
|
|
|
interface DataTransformPropertiesProps {
|
|
nodeId: string;
|
|
data: DataTransformNodeData;
|
|
}
|
|
|
|
const TRANSFORM_TYPES = [
|
|
{ value: "UPPERCASE", label: "대문자 변환", category: "기본" },
|
|
{ value: "LOWERCASE", label: "소문자 변환", category: "기본" },
|
|
{ value: "TRIM", label: "공백 제거", category: "기본" },
|
|
{ value: "CONCAT", label: "문자열 결합", category: "기본" },
|
|
{ value: "SPLIT", label: "문자열 분리", category: "기본" },
|
|
{ value: "REPLACE", label: "문자열 치환", category: "기본" },
|
|
{ value: "EXPLODE", label: "행 확장 (1→N)", category: "고급" },
|
|
{ value: "CAST", label: "타입 변환", category: "고급" },
|
|
{ value: "FORMAT", label: "형식화", category: "고급" },
|
|
{ value: "CALCULATE", label: "계산식", category: "고급" },
|
|
{ value: "JSON_EXTRACT", label: "JSON 추출", category: "고급" },
|
|
{ value: "CUSTOM", label: "사용자 정의", category: "고급" },
|
|
] as const;
|
|
|
|
export function DataTransformProperties({ nodeId, data }: DataTransformPropertiesProps) {
|
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
|
|
|
const [displayName, setDisplayName] = useState(data.displayName || "데이터 변환");
|
|
const [transformations, setTransformations] = useState(data.transformations || []);
|
|
|
|
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
|
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
|
|
|
// 데이터 변경 시 로컬 상태 업데이트
|
|
useEffect(() => {
|
|
setDisplayName(data.displayName || "데이터 변환");
|
|
setTransformations(data.transformations || []);
|
|
}, [data]);
|
|
|
|
// 연결된 소스 노드에서 필드 가져오기
|
|
useEffect(() => {
|
|
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
|
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
|
|
|
const fields: Array<{ name: string; label?: string }> = [];
|
|
sourceNodes.forEach((node) => {
|
|
if (node.type === "tableSource" && node.data.fields) {
|
|
node.data.fields.forEach((field: any) => {
|
|
fields.push({
|
|
name: field.name,
|
|
label: field.label || field.displayName,
|
|
});
|
|
});
|
|
} else if (node.type === "externalDBSource" && node.data.fields) {
|
|
node.data.fields.forEach((field: any) => {
|
|
fields.push({
|
|
name: field.name,
|
|
label: field.label || field.displayName,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
setSourceFields(fields);
|
|
}, [nodeId, nodes, edges]);
|
|
|
|
const handleAddTransformation = () => {
|
|
setTransformations([
|
|
...transformations,
|
|
{
|
|
type: "UPPERCASE" as const,
|
|
sourceField: "",
|
|
targetField: "",
|
|
},
|
|
]);
|
|
};
|
|
|
|
const handleRemoveTransformation = (index: number) => {
|
|
const newTransformations = transformations.filter((_, i) => i !== index);
|
|
setTransformations(newTransformations);
|
|
|
|
// 즉시 반영
|
|
updateNode(nodeId, {
|
|
displayName,
|
|
transformations: newTransformations,
|
|
});
|
|
};
|
|
|
|
const handleTransformationChange = (index: number, field: string, value: any) => {
|
|
const newTransformations = [...transformations];
|
|
|
|
// 필드 변경 시 라벨도 함께 저장
|
|
if (field === "sourceField") {
|
|
const sourceField = sourceFields.find((f) => f.name === value);
|
|
newTransformations[index] = {
|
|
...newTransformations[index],
|
|
sourceField: value,
|
|
sourceFieldLabel: sourceField?.label,
|
|
};
|
|
} else if (field === "targetField") {
|
|
// 타겟 필드는 새로 생성하는 필드이므로 라벨은 사용자가 직접 입력
|
|
newTransformations[index] = {
|
|
...newTransformations[index],
|
|
targetField: value,
|
|
};
|
|
} else {
|
|
newTransformations[index] = { ...newTransformations[index], [field]: value };
|
|
}
|
|
|
|
setTransformations(newTransformations);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
updateNode(nodeId, {
|
|
displayName,
|
|
transformations,
|
|
});
|
|
};
|
|
|
|
const renderTransformationFields = (transform: any, index: number) => {
|
|
const commonFields = (
|
|
<>
|
|
{/* 소스 필드 */}
|
|
{transform.type !== "CONCAT" && (
|
|
<div>
|
|
<Label className="text-xs text-gray-600">소스 필드</Label>
|
|
<Select
|
|
value={transform.sourceField || ""}
|
|
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
|
>
|
|
<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>
|
|
<Label className="text-xs text-gray-600">타겟 필드명 (선택)</Label>
|
|
<Input
|
|
value={transform.targetField || ""}
|
|
onChange={(e) => handleTransformationChange(index, "targetField", e.target.value)}
|
|
placeholder="비어있으면 소스 필드에 적용"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
{transform.targetField ? (
|
|
transform.targetField === transform.sourceField ? (
|
|
<span className="text-indigo-600">✓ 소스 필드를 덮어씁니다</span>
|
|
) : (
|
|
<span className="text-green-600">✓ 새 필드를 생성합니다</span>
|
|
)
|
|
) : (
|
|
<span className="text-indigo-600">비어있음: 소스 필드를 덮어씁니다</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
// 타입별 추가 필드
|
|
switch (transform.type) {
|
|
case "EXPLODE":
|
|
return (
|
|
<>
|
|
{commonFields}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">구분자</Label>
|
|
<Input
|
|
value={transform.delimiter || ","}
|
|
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
|
placeholder="예: , 또는 ; 또는 |"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">이 문자로 분리하여 여러 행으로 확장합니다</p>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
case "CONCAT":
|
|
return (
|
|
<>
|
|
{/* CONCAT은 다중 소스 필드를 지원 - 간소화 버전 */}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">첫 번째 필드</Label>
|
|
<Select
|
|
value={transform.sourceField || ""}
|
|
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue placeholder="첫 번째 필드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sourceFields.map((field) => (
|
|
<SelectItem key={field.name} value={field.name} className="text-xs">
|
|
{field.label || field.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-gray-600">구분자</Label>
|
|
<Input
|
|
value={transform.separator || " "}
|
|
onChange={(e) => handleTransformationChange(index, "separator", e.target.value)}
|
|
placeholder="예: 공백 또는 , 또는 -"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
{commonFields}
|
|
</>
|
|
);
|
|
|
|
case "SPLIT":
|
|
return (
|
|
<>
|
|
{commonFields}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">구분자</Label>
|
|
<Input
|
|
value={transform.delimiter || ","}
|
|
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
|
placeholder="예: , 또는 ; 또는 |"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-gray-600">인덱스 (0부터 시작)</Label>
|
|
<Input
|
|
type="number"
|
|
value={transform.splitIndex !== undefined ? transform.splitIndex : 0}
|
|
onChange={(e) => handleTransformationChange(index, "splitIndex", parseInt(e.target.value))}
|
|
placeholder="0"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">분리된 값 중 몇 번째를 가져올지 지정 (0=첫번째)</p>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
case "REPLACE":
|
|
return (
|
|
<>
|
|
{commonFields}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">찾을 문자열</Label>
|
|
<Input
|
|
value={transform.searchValue || ""}
|
|
onChange={(e) => handleTransformationChange(index, "searchValue", e.target.value)}
|
|
placeholder="예: old"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-gray-600">바꿀 문자열</Label>
|
|
<Input
|
|
value={transform.replaceValue || ""}
|
|
onChange={(e) => handleTransformationChange(index, "replaceValue", e.target.value)}
|
|
placeholder="예: new"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
case "CAST":
|
|
return (
|
|
<>
|
|
{commonFields}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">변환할 타입</Label>
|
|
<Select
|
|
value={transform.castType || "string"}
|
|
onValueChange={(value) => handleTransformationChange(index, "castType", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="string" className="text-xs">
|
|
문자열 (String)
|
|
</SelectItem>
|
|
<SelectItem value="number" className="text-xs">
|
|
숫자 (Number)
|
|
</SelectItem>
|
|
<SelectItem value="boolean" className="text-xs">
|
|
불린 (Boolean)
|
|
</SelectItem>
|
|
<SelectItem value="date" className="text-xs">
|
|
날짜 (Date)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
case "CALCULATE":
|
|
case "FORMAT":
|
|
case "JSON_EXTRACT":
|
|
case "CUSTOM":
|
|
return (
|
|
<>
|
|
{commonFields}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">표현식</Label>
|
|
<Input
|
|
value={transform.expression || ""}
|
|
onChange={(e) => handleTransformationChange(index, "expression", e.target.value)}
|
|
placeholder="예: field1 + field2"
|
|
className="mt-1 h-8 text-xs"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
{transform.type === "CALCULATE" && "계산 수식을 입력하세요 (예: field1 + field2)"}
|
|
{transform.type === "FORMAT" && "형식 문자열을 입력하세요 (예: {0}-{1})"}
|
|
{transform.type === "JSON_EXTRACT" && "JSON 경로를 입력하세요 (예: $.data.name)"}
|
|
{transform.type === "CUSTOM" && "JavaScript 표현식을 입력하세요"}
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
default:
|
|
// UPPERCASE, LOWERCASE, TRIM 등
|
|
return commonFields;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ScrollArea className="h-full">
|
|
<div className="space-y-4 p-4 pb-8">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
|
<Wand2 className="h-4 w-4 text-indigo-600" />
|
|
<span className="font-semibold text-indigo-600">데이터 변환</span>
|
|
</div>
|
|
|
|
{/* 기본 정보 */}
|
|
<div>
|
|
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
|
<div>
|
|
<Label htmlFor="displayName" className="text-xs">
|
|
표시 이름
|
|
</Label>
|
|
<Input
|
|
id="displayName"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
className="mt-1"
|
|
placeholder="노드 표시 이름"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 변환 규칙 */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
|
<p className="text-xs text-gray-500">데이터를 변환할 규칙을 추가하세요</p>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
규칙 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{transformations.length === 0 ? (
|
|
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
|
변환 규칙을 추가하세요
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{transformations.map((transform, index) => (
|
|
<div key={index} className="rounded border bg-indigo-50 p-3">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-xs font-medium text-indigo-700">변환 #{index + 1}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleRemoveTransformation(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">
|
|
{/* 변환 타입 선택 */}
|
|
<div>
|
|
<Label className="text-xs text-gray-600">변환 타입</Label>
|
|
<Select
|
|
value={transform.type}
|
|
onValueChange={(value) => handleTransformationChange(index, "type", value)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<div className="px-2 py-1 text-xs font-semibold text-gray-500">기본 변환</div>
|
|
{TRANSFORM_TYPES.filter((t) => t.category === "기본").map((type) => (
|
|
<SelectItem key={type.value} value={type.value} className="text-xs">
|
|
{type.label}
|
|
</SelectItem>
|
|
))}
|
|
<div className="px-2 py-1 text-xs font-semibold text-gray-500">고급 변환</div>
|
|
{TRANSFORM_TYPES.filter((t) => t.category === "고급").map((type) => (
|
|
<SelectItem key={type.value} value={type.value} className="text-xs">
|
|
{type.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 타입별 필드 렌더링 */}
|
|
{renderTransformationFields(transform, index)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|