cleanup DnD code for CSV mapper ui

This commit is contained in:
Javi Aguilar
2026-05-08 10:24:31 +02:00
parent f7bd7de9e2
commit a08f0bceab
2 changed files with 15 additions and 483 deletions
@@ -1,16 +1,12 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import {
AlertTriangleIcon,
ChevronDownIcon,
ClockIcon,
GripVerticalIcon,
MinusCircleIcon,
PencilIcon,
SparklesIcon,
TextCursorInputIcon,
XIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -30,241 +26,6 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { TFieldMapping, TSourceField, TTargetField } from "../types";
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
const getSourceFieldStateClass = (isDragging: boolean, isMapped: boolean): string => {
if (isDragging) return "border-brand-dark bg-slate-100 opacity-50";
if (isMapped) return "border-green-300 bg-green-50 text-green-800";
return "border-slate-200 bg-white hover:border-slate-300";
};
export const DraggableSourceField = ({ field, isMapped }: DraggableSourceFieldProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${getSourceFieldStateClass(isDragging, isMapped)}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
};
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
if (isActive) return "border-brand-dark bg-slate-100";
if (hasMapping) return "border-green-300 bg-green-50";
return "border-dashed border-slate-300 bg-slate-50";
};
interface RemoveMappingButtonProps {
onClick: () => void;
variant: "green" | "blue";
}
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
return (
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
<XIcon className={`h-3 w-3 ${iconClass}`} />
</button>
);
};
interface EnumTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const EnumTargetFieldContent = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
t,
}: EnumTargetFieldContentProps) => {
return (
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>
</div>
{mappedSourceField && !mapping?.staticValue ? (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700">&larr; {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
) : (
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
</SelectTrigger>
<SelectContent>
{field.enumValues?.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
};
interface StringTargetFieldContentProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
hasMapping: unknown;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
t: (key: string) => string;
}
const StringTargetFieldContent = ({
field,
mappedSourceField,
mapping,
hasMapping,
onRemoveMapping,
onStaticValueChange,
t,
}: StringTargetFieldContentProps) => {
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
return (
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= &ldquo;{mapping.staticValue}&rdquo;
</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues
? `e.g., ${field.exampleStaticValues[0]}`
: t("workspace.unify.enter_value")
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.unify.drop_field_or")}</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
{t("workspace.unify.set_value")}
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
);
};
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export type TAutoMapState = "high" | "medium" | "low";
interface AutoMappedBadgeProps {
@@ -307,112 +68,6 @@ export const AutoMappedBadge = ({ state, sourceColumn }: AutoMappedBadgeProps) =
);
};
export const DroppableTargetField = ({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) => {
const { t } = useTranslation();
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
const containerClass = cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
getMappingStateClass(!!isActive, hasMapping)
);
if (field.type === "enum" && field.enumValues) {
return (
<div ref={setNodeRef} className={containerClass}>
<EnumTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
if (field.type === "string") {
return (
<div ref={setNodeRef} className={containerClass}>
<StringTargetFieldContent
field={field}
mappedSourceField={mappedSourceField}
mapping={mapping}
hasMapping={hasMapping}
onRemoveMapping={onRemoveMapping}
onStaticValueChange={onStaticValueChange}
t={t}
/>
</div>
);
}
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("workspace.unify.feedback_date");
return value;
};
return (
<div ref={setNodeRef} className={containerClass}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
</div>
)}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
</div>
)}
{!hasMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
};
const SENTINEL = {
COLUMN_PREFIX: "__col__:",
ENUM_PREFIX: "__enum__:",
@@ -1,20 +1,12 @@
"use client";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import {
CSV_FIELD_GROUPS,
CSV_TARGET_FIELDS,
FEEDBACK_RECORD_FIELDS,
TFieldMapping,
TSourceField,
TTargetField,
} from "../types";
import { CSV_FIELD_GROUPS, CSV_TARGET_FIELDS, TFieldMapping, TSourceField, TTargetField } from "../types";
import { TMappingConfidence, routeResponseValueTarget } from "../utils";
import { DraggableSourceField, DroppableTargetField, FormTargetField, TAutoMapState } from "./mapping-field";
import { FormTargetField, TAutoMapState } from "./mapping-field";
interface MappingUIProps {
sourceFields: TSourceField[];
@@ -38,135 +30,20 @@ export function MappingUI({
confidenceByTargetId,
sampleRow,
}: MappingUIProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const sourceFieldId = active.id as string;
const targetFieldId = over.id as string;
const newMappings = mappings.filter(
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
};
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
if (connectorType === "csv") {
return (
<CsvMappingForm
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
);
switch (connectorType) {
case "csv":
return (
<CsvMappingForm
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
);
default:
return null;
}
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.source_fields")}</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("workspace.unify.no_source_fields_loaded")}</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("workspace.unify.feedback_record_fields")}
</h4>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("workspace.unify.required")}
</p>
{requiredFields.map((targetField) => (
<DroppableTargetField
key={targetField.id}
field={targetField}
mappedSourceField={getMappedSourceField(targetField.id) ?? null}
mapping={getMappingForTarget(targetField.id)}
onRemoveMapping={() => handleRemoveMapping(targetField.id)}
onStaticValueChange={(value) => handleStaticValueChange(targetField.id, value)}
/>
))}
</div>
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("workspace.unify.optional")}
</p>
{optionalFields.map((targetField) => (
<DroppableTargetField
key={targetField.id}
field={targetField}
mappedSourceField={getMappedSourceField(targetField.id) ?? null}
mapping={getMappingForTarget(targetField.id)}
onRemoveMapping={() => handleRemoveMapping(targetField.id)}
onStaticValueChange={(value) => handleStaticValueChange(targetField.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="rounded-md border border-brand-dark bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
interface CsvMappingFormProps {