노드별 컬럼 검색선택기능
This commit is contained in:
parent
31bd9c26b7
commit
6d54a4c9ea
|
|
@ -67,6 +67,19 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||||
|
||||
// 내부 DB 컬럼 관련 상태
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
}
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [fieldOpenState, setFieldOpenState] = useState<boolean[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
|
|
@ -101,6 +114,18 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// 🔥 내부 DB 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "internal" && targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetType, targetTable]);
|
||||
|
||||
// whereConditions 변경 시 fieldOpenState 초기화
|
||||
useEffect(() => {
|
||||
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||
}, [whereConditions.length]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
|
@ -171,6 +196,28 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
}
|
||||
};
|
||||
|
||||
// 🔥 내부 DB 컬럼 로딩
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
const response = await tableTypeApi.getColumns(tableName);
|
||||
if (response && Array.isArray(response)) {
|
||||
const columnInfos: ColumnInfo[] = response.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label,
|
||||
dataType: col.dataType || col.data_type || "text",
|
||||
isNullable: col.isNullable !== undefined ? col.isNullable : true,
|
||||
}));
|
||||
setTargetColumns(columnInfos);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||
|
|
@ -186,18 +233,22 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -639,64 +690,169 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 로딩 상태 */}
|
||||
{targetType === "internal" && targetTable && columnsLoading && (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
컬럼 정보를 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 미선택 안내 */}
|
||||
{targetType === "internal" && !targetTable && (
|
||||
<div className="rounded border border-dashed border-gray-300 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{whereConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{whereConditions.map((condition, index) => {
|
||||
// 현재 타입에 따라 사용 가능한 컬럼 리스트 결정
|
||||
const availableColumns =
|
||||
targetType === "internal"
|
||||
? targetColumns
|
||||
: targetType === "external"
|
||||
? externalColumns.map((col) => ({
|
||||
columnName: col.column_name,
|
||||
columnLabel: col.column_name,
|
||||
dataType: col.data_type,
|
||||
isNullable: true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
return (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</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="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* 필드 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
{availableColumns.length > 0 ? (
|
||||
<Popover
|
||||
open={fieldOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...fieldOpenState];
|
||||
newState[index] = open;
|
||||
setFieldOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={fieldOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{condition.field
|
||||
? (() => {
|
||||
const col = availableColumns.find((c) => c.columnName === condition.field);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || condition.field}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{col?.dataType}</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>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleConditionChange(index, "field", currentValue);
|
||||
const newState = [...fieldOpenState];
|
||||
newState[index] = false;
|
||||
setFieldOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
condition.field === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||||
|
|
@ -705,7 +861,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -37,6 +38,7 @@ interface ColumnInfo {
|
|||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
columnDefault?: string | null;
|
||||
}
|
||||
|
||||
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
||||
|
|
@ -63,6 +65,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
// REST API 소스 노드 연결 여부
|
||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리 (필드 매핑)
|
||||
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
|
|
@ -118,6 +124,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
}, [fieldMappings.length]);
|
||||
|
||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
|
|
@ -340,12 +352,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||
let isNullable = true; // 기본값: nullable
|
||||
|
||||
if (typeof isNullableValue === "boolean") {
|
||||
isNullable = isNullableValue;
|
||||
} else if (typeof isNullableValue === "string") {
|
||||
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
|
||||
} else if (typeof isNullableValue === "number") {
|
||||
isNullable = isNullableValue !== 0;
|
||||
}
|
||||
|
||||
return {
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable,
|
||||
columnDefault: col.column_default ?? col.columnDefault ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||||
|
|
@ -449,12 +476,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열 초기화
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열도 업데이트
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -1077,35 +1112,87 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
// 일반 소스인 경우: 드롭다운 선택
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
// 일반 소스인 경우: Combobox 선택
|
||||
<Popover
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || mapping.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>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.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>
|
||||
)}
|
||||
{hasRestAPISource && (
|
||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||
|
|
@ -1116,43 +1203,134 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */}
|
||||
{/* 타겟 필드 Combobox (🔥 타입별 컬럼 사용) */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
<Popover
|
||||
open={mappingTargetFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 🔥 내부 DB 컬럼 */}
|
||||
{targetType === "internal" &&
|
||||
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>
|
||||
))}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.targetField
|
||||
? (() => {
|
||||
if (targetType === "internal") {
|
||||
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{col?.dataType}
|
||||
{col && !col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const col = externalColumns.find(
|
||||
(c) => c.column_name === mapping.targetField,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.column_name || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{col?.data_type}</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>
|
||||
{/* 🔥 내부 DB 컬럼 */}
|
||||
{targetType === "internal" &&
|
||||
targetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{col.dataType}
|
||||
{col.columnDefault ? (
|
||||
<span className="text-blue-600"> 🔵기본값</span>
|
||||
) : (
|
||||
!col.isNullable && <span className="text-red-500"> *필수</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{/* 🔥 외부 DB 컬럼 */}
|
||||
{targetType === "external" &&
|
||||
externalColumns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.column_name}</span>
|
||||
<span className="text-muted-foreground">{col.data_type}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 🔥 외부 DB 컬럼 */}
|
||||
{targetType === "external" &&
|
||||
externalColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.column_name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">{col.column_name}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.data_type}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Search } from "lucide-react";
|
||||
import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
|
|
@ -62,6 +61,9 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [whereFieldOpenState, setWhereFieldOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "참조 조회");
|
||||
|
|
@ -72,6 +74,11 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
setOutputFields(data.outputFields || []);
|
||||
}, [data]);
|
||||
|
||||
// whereConditions 변경 시 whereFieldOpenState 초기화
|
||||
useEffect(() => {
|
||||
setWhereFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||
}, [whereConditions.length]);
|
||||
|
||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
||||
useEffect(() => {
|
||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||
|
|
@ -187,7 +194,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
|
||||
// WHERE 조건 추가
|
||||
const handleAddWhereCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
|
|
@ -195,11 +202,15 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
value: "",
|
||||
valueType: "static",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveWhereCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -455,23 +466,81 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 필드 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
|
||||
<Popover
|
||||
open={whereFieldOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...whereFieldOpenState];
|
||||
newState[index] = open;
|
||||
setWhereFieldOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={whereFieldOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{condition.field
|
||||
? (() => {
|
||||
const field = referenceColumns.find((f) => f.name === condition.field);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate">{field?.label || condition.field}</span>
|
||||
{field?.type && (
|
||||
<span className="text-muted-foreground text-xs">{field.type}</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>
|
||||
{referenceColumns.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleWhereConditionChange(index, "field", currentValue);
|
||||
const newState = [...whereFieldOpenState];
|
||||
newState[index] = false;
|
||||
setWhereFieldOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
condition.field === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.type && (
|
||||
<span className="text-muted-foreground text-[10px]">{field.type}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,7 @@ interface ColumnInfo {
|
|||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
columnDefault?: string | null;
|
||||
}
|
||||
|
||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
||||
|
|
@ -85,6 +86,11 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
// REST API 소스 노드 연결 여부
|
||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [conflictKeysOpenState, setConflictKeysOpenState] = useState<boolean[]>([]);
|
||||
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
|
|
@ -129,6 +135,17 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// conflictKeys 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setConflictKeysOpenState(new Array(conflictKeys.length).fill(false));
|
||||
}, [conflictKeys.length]);
|
||||
|
||||
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
}, [fieldMappings.length]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
|
|
@ -326,12 +343,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
setColumnsLoading(true);
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||
let isNullable = true; // 기본값: nullable
|
||||
|
||||
if (typeof isNullableValue === "boolean") {
|
||||
isNullable = isNullableValue;
|
||||
} else if (typeof isNullableValue === "string") {
|
||||
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
|
||||
} else if (typeof isNullableValue === "number") {
|
||||
isNullable = isNullableValue !== 0;
|
||||
}
|
||||
|
||||
return {
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable,
|
||||
columnDefault: col.column_default ?? col.columnDefault ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
} catch (error) {
|
||||
|
|
@ -401,12 +433,20 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열 초기화
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열도 업데이트
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -934,24 +974,46 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 충돌 키 추가 드롭다운 */}
|
||||
<Select onValueChange={handleAddConflictKey}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="충돌 키 추가..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns
|
||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
||||
.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}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 충돌 키 추가 Combobox */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
충돌 키 추가...
|
||||
<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>
|
||||
{targetColumns
|
||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleAddConflictKey(currentValue);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1010,32 +1072,84 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
<Popover
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || mapping.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>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.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>
|
||||
|
||||
|
|
@ -1043,30 +1157,91 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
<ArrowRight className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
{/* 타겟 필드 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
<Popover
|
||||
open={mappingTargetFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.targetField
|
||||
? (() => {
|
||||
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{col?.dataType}
|
||||
{col && !col.isNullable && <span className="text-red-500">*</span>}
|
||||
</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>
|
||||
{targetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{col.dataType}
|
||||
{col.columnDefault ? (
|
||||
<span className="text-blue-600"> 🔵기본값</span>
|
||||
) : (
|
||||
!col.isNullable && <span className="text-red-500"> *필수</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
|
|
@ -1092,7 +1267,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue