feat: DELETE 노드 WHERE 조건에 소스 필드 선택 기능 추가

- 소스 필드 목록을 연결된 입력 노드에서 자동으로 로딩
- WHERE 조건에 소스 필드 선택 Combobox 추가
- 정적 값과 소스 필드 중 선택 가능
- 조건 변경 시 자동 저장 기능 추가
This commit is contained in:
leeheejin 2026-01-09 11:44:14 +09:00
parent 65e1c1a995
commit e8516d9d6b
1 changed files with 244 additions and 6 deletions

View File

@ -4,7 +4,7 @@
* DELETE
*/
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps {
data: DeleteActionNodeData;
}
// 소스 필드 타입
interface SourceField {
name: string;
label?: string;
}
const OPERATORS = [
{ value: "EQUALS", label: "=" },
{ value: "NOT_EQUALS", label: "≠" },
@ -34,7 +40,7 @@ const OPERATORS = [
] as const;
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
const [targetTable, setTargetTable] = useState(data.targetTable);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
// 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<SourceField[]>([]);
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
// whereConditions 변경 시 fieldOpenState 초기화
useEffect(() => {
setFieldOpenState(new Array(whereConditions.length).fill(false));
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
}, [whereConditions.length]);
// 🆕 소스 필드 로딩 (연결된 입력 노드에서)
const loadSourceFields = useCallback(async () => {
// 현재 노드로 연결된 엣지 찾기
const incomingEdges = edges.filter((e) => e.target === nodeId);
console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
if (incomingEdges.length === 0) {
console.log("⚠️ 연결된 소스 노드가 없습니다");
setSourceFields([]);
return;
}
const fields: SourceField[] = [];
const processedFields = new Set<string>();
for (const edge of incomingEdges) {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) continue;
console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
// 소스 노드 타입에 따라 필드 추출
if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
// 트리거 노드: 테이블 컬럼 조회
try {
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("트리거 노드 컬럼 로딩 실패:", error);
}
} else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
// 테이블 소스 노드
try {
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
}
} else if (sourceNode.type === "condition") {
// 조건 노드: 연결된 이전 노드에서 필드 가져오기
const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
for (const condEdge of conditionIncomingEdges) {
const condSourceNode = nodes.find((n) => n.id === condEdge.source);
if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
try {
const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("조건 노드 소스 컬럼 로딩 실패:", error);
}
}
}
}
}
console.log("✅ DELETE 노드 소스 필드:", fields);
setSourceFields(fields);
}, [nodeId, nodes, edges]);
// 소스 필드 로딩
useEffect(() => {
loadSourceFields();
}, [loadSourceFields]);
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
field: "",
operator: "EQUALS",
value: "",
sourceField: undefined,
staticValue: undefined,
},
];
setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false));
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
};
const handleRemoveCondition = (index: number) => {
const newConditions = whereConditions.filter((_, i) => i !== index);
setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false));
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
};
const handleConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions];
newConditions[index] = { ...newConditions[index], [field]: value };
setWhereConditions(newConditions);
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
};
const handleSave = () => {
@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
</Select>
</div>
{/* 🆕 소스 필드 - Combobox */}
<div>
<Label className="text-xs text-gray-600"></Label>
<Label className="text-xs text-gray-600"> ()</Label>
{sourceFields.length > 0 ? (
<Popover
open={sourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...sourceFieldsOpenState];
newState[index] = open;
setSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{condition.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === condition.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || condition.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
)}
</div>
);
})()
: "소스 필드 선택 (선택)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="_NONE_"
onSelect={() => {
handleConditionChange(index, "sourceField", undefined);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs text-gray-400 sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!condition.sourceField ? "opacity-100" : "opacity-0",
)}
/>
( )
</CommandItem>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleConditionChange(index, "sourceField", currentValue);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="mt-1 rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center text-xs text-gray-500">
</div>
)}
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={condition.value as string}
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
placeholder="비교 값"
value={condition.staticValue || condition.value || ""}
onChange={(e) => {
handleConditionChange(index, "staticValue", e.target.value || undefined);
handleConditionChange(index, "value", e.target.value);
}}
placeholder="비교할 고정 값"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div>
</div>