revamp CSV mapping UI, with field descriptors

This commit is contained in:
Javi Aguilar
2026-05-07 12:25:49 +02:00
parent b11e5db899
commit 467add1814
13 changed files with 1530 additions and 191 deletions
@@ -283,3 +283,134 @@ describe("transformCsvRowsToFeedbackRecords", () => {
expect(skipped).toBe(0);
});
});
describe("response_value routing", () => {
const responseMappings = (fieldType: string): TConnectorFieldMapping[] => [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", fieldType),
makeMapping("timestamp", "collected_at"),
];
test("text routes to value_text", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "great service", question: "q1", timestamp: "2026-01-15" },
responseMappings("text"),
TENANT
);
expect(result!.value_text).toBe("great service");
expect(result!.value_number).toBeUndefined();
});
test("categorical routes to value_text", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "option_a", question: "q1", timestamp: "2026-01-15" },
responseMappings("categorical"),
TENANT
);
expect(result!.value_text).toBe("option_a");
});
test.each(["number", "nps", "csat", "ces", "rating"])("%s routes to value_number", (fieldType) => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "9", question: "q1", timestamp: "2026-01-15" },
responseMappings(fieldType),
TENANT
);
expect(result!.value_number).toBe(9);
expect(result!.value_text).toBeUndefined();
});
test("boolean routes to value_boolean", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "true", question: "q1", timestamp: "2026-01-15" },
responseMappings("boolean"),
TENANT
);
expect(result!.value_boolean).toBe(true);
});
test("date routes to value_date", () => {
const result = transformCsvRowToFeedbackRecord(
{ answer: "2026-03-01", question: "q1", timestamp: "2026-01-15" },
responseMappings("date"),
TENANT
);
expect(result!.value_date).toBe("2026-03-01T00:00:00.000Z");
});
test("invalid field_type causes the row to be skipped", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "not_a_real_enum"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ answer: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result).toBeNull();
});
test("missing field_type causes the row to be skipped", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("answer", "response_value"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ answer: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result).toBeNull();
});
});
describe("tenant_id defense-in-depth", () => {
test("ignores a user-supplied tenant_id mapping and uses the connector value", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("malicious", "tenant_id"),
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const row = {
malicious: "stolen-tenant",
feedback_text: "x",
question: "q1",
timestamp: "2026-01-15",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.tenant_id).toBe(TENANT);
expect(result!.tenant_id).not.toBe("stolen-tenant");
});
test("ignores a static tenant_id mapping", () => {
const mappings: TConnectorFieldMapping[] = [
makeMapping("", "tenant_id", "stolen-tenant"),
makeMapping("feedback_text", "value_text"),
makeMapping("question", "field_id"),
makeMapping("", "source_type", "csv"),
makeMapping("", "field_type", "text"),
makeMapping("timestamp", "collected_at"),
];
const result = transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15" },
mappings,
TENANT
);
expect(result!.tenant_id).toBe(TENANT);
});
});
+56 -13
View File
@@ -1,5 +1,11 @@
import { randomUUID } from "crypto";
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
import {
TConnectorFieldMapping,
THubFieldType,
THubTargetField,
ZHubFieldType,
} from "@formbricks/types/connector";
import { routeResponseValueTarget } from "@/modules/ee/unify-feedback/sources/utils";
import { FeedbackRecordCreateParams } from "@/modules/hub";
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
@@ -33,19 +39,36 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb
const resolveValue = (
row: Record<string, string>,
mapping: TConnectorFieldMapping
mapping: TConnectorFieldMapping,
effectiveTargetFieldId: THubTargetField
): string | number | boolean | undefined => {
if (mapping.staticValue) {
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(effectiveTargetFieldId)) {
return new Date().toISOString();
}
return coerceValue(mapping.staticValue, mapping.targetFieldId);
return coerceValue(mapping.staticValue, effectiveTargetFieldId);
}
const rawValue = row[mapping.sourceFieldId];
if (rawValue === undefined || rawValue === null) return undefined;
return coerceValue(rawValue, mapping.targetFieldId);
return coerceValue(rawValue, effectiveTargetFieldId);
};
// Resolve the row's field_type up front so response_value routing is consistent for the row.
// Returns null if no valid field_type is available (row is then skipped).
const resolveFieldTypeForRow = (
row: Record<string, string>,
mappings: TConnectorFieldMapping[]
): THubFieldType | null => {
const mapping = mappings.find((m) => m.targetFieldId === "field_type");
if (!mapping) return null;
const raw = mapping.staticValue ?? row[mapping.sourceFieldId];
if (!raw) return null;
const parsed = ZHubFieldType.safeParse(raw.trim());
return parsed.success ? parsed.data : null;
};
/**
@@ -63,18 +86,38 @@ export const transformCsvRowToFeedbackRecord = (
): FeedbackRecordCreateParams | null => {
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
for (const mapping of mappings) {
const value = resolveValue(row, mapping);
if (value === undefined) continue;
// Defense-in-depth: never honor a user-supplied tenant_id mapping. The UI hides this field, but
// a hand-crafted payload could still include one. Backfill from the connector authoritatively.
const safeMappings = mappings.filter((m) => m.targetFieldId !== "tenant_id");
if (JSON_FIELDS.has(mapping.targetFieldId)) {
// Resolve field_type once per row; response_value routing depends on it.
const fieldType = resolveFieldTypeForRow(row, safeMappings);
if (!fieldType) return null;
for (const mapping of safeMappings) {
let effectiveTargetFieldId: THubTargetField;
if (mapping.targetFieldId === "response_value") {
try {
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
effectiveTargetFieldId = routeResponseValueTarget(fieldType);
} catch {
record[mapping.targetFieldId] = { raw: value };
// routing is exhaustive; fail closed if THubFieldType ever drifts.
return null;
}
} else {
record[mapping.targetFieldId] = value;
effectiveTargetFieldId = mapping.targetFieldId;
}
const value = resolveValue(row, mapping, effectiveTargetFieldId);
if (value === undefined) continue;
if (JSON_FIELDS.has(effectiveTargetFieldId)) {
try {
record[effectiveTargetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
} catch {
record[effectiveTargetFieldId] = { raw: value };
}
} else {
record[effectiveTargetFieldId] = value;
}
}
@@ -91,7 +134,7 @@ export const transformCsvRowToFeedbackRecord = (
}
if (!("submission_id" in record)) {
const submissionMapped = mappings.some((m) => m.targetFieldId === "submission_id");
const submissionMapped = safeMappings.some((m) => m.targetFieldId === "submission_id");
if (submissionMapped) {
return null;
}
+24 -1
View File
@@ -3660,27 +3660,50 @@
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_duplicated_successfully": "Connector duplicated successfully",
"connector_name": "Connector Name",
"connector_name_hint": "How this connector appears in your dashboard. Auto-filled from the uploaded filename — edit anytime.",
"connector_status_updated_successfully": "Connector status updated successfully",
"connector_updated_successfully": "Connector updated successfully",
"connectors": "Connectors",
"create_mapping": "Create mapping",
"created_by": "Created by",
"csv_advanced": "Advanced",
"csv_advanced_hint": "Less common fields. Set them when relevant.",
"csv_at_least_one_row": "CSV must contain at least one data row.",
"csv_auto_mapped": "Auto-mapped",
"csv_auto_mapped_tooltip": "We mapped this from \"{column}\" because the header looked similar. Confirm or change it.",
"csv_auto_mapped_verify": "Auto-mapped — please verify",
"csv_basic_required": "Basic (required)",
"csv_basic_required_hint": "Pick a CSV column, or set a fixed value applied to every row.",
"csv_column_used_by": "Mapped to: {target}",
"csv_columns": "CSV Columns",
"csv_data_preview": "Data preview",
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_fixed_value_action": "Set a fixed value…",
"csv_fixed_value_label": "Fixed value: {value}",
"csv_import": "CSV Import",
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {max} records allowed.",
"csv_now_label": "Now (use the import time)",
"csv_pick_column_placeholder": "Pick a column or set a value…",
"csv_required_fields_missing": "Please map required fields before saving: {fields}",
"csv_response_preview": "Sample: \"{sample}\" → stored as {target}.",
"csv_sample_label": "Sample CSV",
"csv_source_context": "Source Context",
"csv_source_context_hint": "Identifies where this batch of feedback came from.",
"csv_unmapped_columns": "Unmapped columns ({count}): {columns}",
"csv_unmapped_columns_explainer": "These columns aren't used by any Feedback Record field. They'll be ignored at import.",
"custom_source_type": "Custom source type",
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"dont_include": "Don't include this field",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_csv_mapping": "Edit CSV mapping",
@@ -3769,7 +3792,7 @@
"set_value": "set value",
"setup_connection": "Setup connection",
"showing_count_loaded": "Showing {count} records",
"showing_rows": "Showing 3 of {count} rows",
"showing_rows": "Showing {visible} of {total} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
@@ -165,15 +165,15 @@ export const getAccessControlPermission = async (organizationId: string): Promis
};
export const getIsUnifyFeedbackEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "unifyFeedback");
return true; //getCustomPlanFeaturePermission(organizationId, "unifyFeedback");
};
export const getIsFeedbackDirectoriesEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "feedbackDirectories");
return true; // getCustomPlanFeaturePermission(organizationId, "feedbackDirectories");
};
export const getIsDashboardsEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "dashboards");
return true; //getCustomPlanFeaturePermission(organizationId, "dashboards");
};
export const getOrganizationWorkspacesLimit = async (organizationId: string): Promise<number> => {
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -43,6 +43,7 @@ import {
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import {
CSV_HIDDEN_STATIC_MAPPINGS,
TCreateConnectorStep,
TFieldMapping,
TFormbricksConnectorForm,
@@ -53,9 +54,8 @@ import {
import {
TConnectorOptionId,
TEnumValidationError,
areAllRequiredFieldsMapped,
areAllRequiredCsvFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
validateEnumMappings,
} from "../utils";
@@ -156,6 +156,7 @@ export const CreateConnectorModal = ({
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
const userEditedConnectorNameRef = useRef(false);
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
@@ -224,6 +225,7 @@ export const CreateConnectorModal = ({
setEnumValidationErrors([]);
setResponseCountBySurvey({});
setCsvConnectorName("");
userEditedConnectorNameRef.current = false;
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories[0]?.id ?? null);
@@ -350,6 +352,15 @@ export const CreateConnectorModal = ({
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
if (!requiredCheck.valid) {
toast.error(
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
);
return;
}
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
@@ -361,11 +372,16 @@ export const CreateConnectorModal = ({
setIsCreating(true);
// Strip any user-supplied tenant_id and merge hidden static mappings (source_type=csv).
const protectedIds = ["tenant_id", "source_type"];
const userMappings = mappings.filter((m) => protectedIds.every((id) => m.targetFieldId !== id));
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
const connectorId = await onCreateConnector({
name: csvConnectorName.trim(),
type: "csv",
feedbackDirectoryId: selectedDirectoryId,
fieldMappings: mappings.length > 0 ? mappings : undefined,
fieldMappings,
});
if (connectorId && csvParsedData.length > 0) {
@@ -378,13 +394,16 @@ export const CreateConnectorModal = ({
};
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
const areCsvRequiredFieldsMapped = areAllRequiredCsvFieldsMapped(mappings).valid;
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
setSourceFields(fields);
}
const handleSuggestConnectorName = (name: string) => {
if (userEditedConnectorNameRef.current) return;
setCsvConnectorName(name);
};
const handleCsvConnectorNameChange = (value: string) => {
userEditedConnectorNameRef.current = true;
setCsvConnectorName(value);
};
return (
@@ -513,13 +532,14 @@ export const CreateConnectorModal = ({
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
<Label htmlFor="connectorName">{t("workspace.unify.connector_name")}</Label>
<Input
id="connectorName"
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
onChange={(event) => handleCsvConnectorNameChange(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
<p className="text-xs text-slate-500">{t("workspace.unify.connector_name_hint")}</p>
</div>
{directories.length === 0 && <NoFeedbackDirectoryAlert workspaceId={workspaceId} t={t} />}
@@ -533,8 +553,8 @@ export const CreateConnectorModal = ({
setEnumValidationErrors([]);
}}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
onParsedDataChange={setCsvParsedData}
onSuggestConnectorName={handleSuggestConnectorName}
/>
</div>
@@ -1,14 +1,20 @@
"use client";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon } from "lucide-react";
import { useState } from "react";
import { ArrowUpFromLineIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import { validateCsvFile } from "../utils";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
import {
TMappingConfidence,
autoMapCsvSourceFields,
parseCSVColumnsToFields,
titleizeFromFileName,
validateCsvFile,
} from "../utils";
import { MappingUI } from "./mapping-ui";
interface CsvConnectorUIProps {
@@ -16,8 +22,8 @@ interface CsvConnectorUIProps {
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
onSourceFieldsChange: (fields: TSourceField[]) => void;
onLoadSampleCSV: () => void;
onParsedDataChange?: (data: Record<string, string>[]) => void;
onSuggestConnectorName?: (name: string) => void;
}
export function CsvConnectorUI({
@@ -25,14 +31,35 @@ export function CsvConnectorUI({
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
onParsedDataChange,
}: CsvConnectorUIProps) {
onSuggestConnectorName,
}: Readonly<CsvConnectorUIProps>) {
const { t } = useTranslation();
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [csvTotalRows, setCsvTotalRows] = useState(0);
const [showMapping, setShowMapping] = useState(false);
const [csvError, setCsvError] = useState("");
const [confidenceByTargetId, setConfidenceByTargetId] = useState<Record<string, TMappingConfidence>>({});
const [previewOpen, setPreviewOpen] = useState(true);
const [sampleRow, setSampleRow] = useState<Record<string, string> | undefined>(undefined);
// Track whether the user has manually edited the source_name mapping after auto-population.
// On re-upload, only overwrite source_name if the user hasn't touched it.
const userEditedSourceNameRef = useRef(false);
const lastAutoSourceNameRef = useRef<string | undefined>(undefined);
useEffect(() => {
const sourceNameMapping = mappings.find((m) => m.targetFieldId === "source_name");
const current = sourceNameMapping?.staticValue ?? sourceNameMapping?.sourceFieldId;
if (
lastAutoSourceNameRef.current !== undefined &&
current !== undefined &&
current !== lastAutoSourceNameRef.current
) {
userEditedSourceNameRef.current = true;
}
}, [mappings]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
@@ -41,6 +68,66 @@ export function CsvConnectorUI({
}
};
// User-driven mapping changes clear the auto-map badge for any target whose mapping changed,
// so the badge sticks until auto-map runs again. Even if the user picks the same value back,
// the field is now "user-confirmed" rather than "auto-mapped". Auto-map itself bypasses this
// wrapper and uses `onMappingsChange` directly.
const handleUserMappingsChange = (newMappings: TFieldMapping[]) => {
const oldByTarget = new Map(mappings.map((m) => [m.targetFieldId, m]));
const newByTarget = new Map(newMappings.map((m) => [m.targetFieldId, m]));
const changedIds = new Set<string>();
for (const [id, m] of newByTarget) {
const prev = oldByTarget.get(id);
if (!prev || prev.sourceFieldId !== m.sourceFieldId || prev.staticValue !== m.staticValue) {
changedIds.add(id);
}
}
for (const id of oldByTarget.keys()) {
if (!newByTarget.has(id)) changedIds.add(id);
}
if (changedIds.size > 0) {
setConfidenceByTargetId((prev) => {
const next = { ...prev };
for (const id of changedIds) delete next[id];
return next;
});
}
onMappingsChange(newMappings);
};
const applyAutoMapping = (fields: TSourceField[], sampleRow: Record<string, string>, fileName: string) => {
const { mappings: autoMappings, confidence } = autoMapCsvSourceFields({
sourceFields: fields,
sampleRow,
fileName,
});
const autoSourceNameStatic = autoMappings.find((m) => m.targetFieldId === "source_name")?.staticValue;
// Preserve a user-edited source_name mapping across re-uploads.
if (userEditedSourceNameRef.current) {
const existingSourceName = mappings.find((m) => m.targetFieldId === "source_name");
if (existingSourceName) {
const filtered = autoMappings.filter((m) => m.targetFieldId !== "source_name");
onMappingsChange([...filtered, existingSourceName]);
const nextConfidence = { ...confidence };
delete nextConfidence.source_name;
setConfidenceByTargetId(nextConfidence);
} else {
onMappingsChange(autoMappings);
setConfidenceByTargetId(confidence);
}
} else {
onMappingsChange(autoMappings);
setConfidenceByTargetId(confidence);
}
lastAutoSourceNameRef.current = autoSourceNameStatic;
onSuggestConnectorName?.(titleizeFromFileName(fileName));
};
const processCSVFile = (file: File) => {
setCsvError("");
@@ -73,6 +160,7 @@ export function CsvConnectorUI({
];
setCsvFile(file);
setCsvPreview(preview);
setCsvTotalRows(validRecords.length);
const fields: TSourceField[] = headers.map((header) => ({
id: header,
@@ -82,6 +170,10 @@ export function CsvConnectorUI({
}));
onSourceFieldsChange(fields);
onParsedDataChange?.(validRecords);
setSampleRow(validRecords[0]);
applyAutoMapping(fields, validRecords[0], file.name);
setShowMapping(true);
} catch (error) {
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
@@ -106,67 +198,103 @@ export function CsvConnectorUI({
};
const handleLoadSample = () => {
onLoadSampleCSV();
const fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
const synthSampleRow = Object.fromEntries(fields.map((f) => [f.id, f.sampleValue ?? ""])) as Record<
string,
string
>;
onSourceFieldsChange(fields);
onParsedDataChange?.([]);
setSampleRow(synthSampleRow);
// Build a synthetic 1-row preview so the data preview block has something to render.
setCsvPreview([fields.map((f) => f.id), fields.map((f) => f.sampleValue ?? "")]);
setCsvTotalRows(1);
applyAutoMapping(fields, synthSampleRow, "sample-feedback.csv");
setShowMapping(true);
};
if (showMapping && sourceFields.length > 0) {
const sourceLabel = csvFile?.name ?? t("workspace.unify.csv_sample_label");
return (
<div className="space-y-4">
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvError("");
setShowMapping(false);
onSourceFieldsChange([]);
onParsedDataChange?.([]);
}}>
{t("workspace.unify.change_file")}
</Button>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{sourceLabel}</span>
<Badge text={`${csvTotalRows} rows`} type="gray" size="tiny" />
</div>
)}
<Button
variant="secondary"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvTotalRows(0);
setCsvError("");
setShowMapping(false);
setConfidenceByTargetId({});
setSampleRow(undefined);
userEditedSourceNameRef.current = false;
lastAutoSourceNameRef.current = undefined;
onSourceFieldsChange([]);
onParsedDataChange?.([]);
}}>
{t("workspace.unify.change_file")}
</Button>
</div>
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={`${header}-${i}`} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
<button
type="button"
onClick={() => setPreviewOpen((v) => !v)}
className="flex w-full items-center gap-1 bg-slate-50 px-3 py-2 text-left text-xs font-medium text-slate-700 hover:bg-slate-100">
{previewOpen ? (
<ChevronDownIcon className="h-3 w-3" />
) : (
<ChevronRightIcon className="h-3 w-3" />
)}
{t("workspace.unify.csv_data_preview")}
{(() => {
const visible = Math.min(3, Math.max(csvPreview.length - 1, 0));
if (visible >= csvTotalRows) return null;
return (
<span className="text-slate-500">
({t("workspace.unify.showing_rows", { visible, total: csvTotalRows })})
</span>
);
})()}
</button>
{previewOpen && (
<>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th
key={`${header}-${i}`}
className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
))}
</tr>
))}
</tbody>
</table>
</div>
{csvPreview.length > 4 && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
{t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
</div>
</tbody>
</table>
</div>
</>
)}
</div>
)}
@@ -174,9 +302,13 @@ export function CsvConnectorUI({
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
onMappingsChange={handleUserMappingsChange}
connectorType="csv"
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
<UnmappedColumnsFooter sourceFields={sourceFields} mappings={mappings} />
</div>
);
}
@@ -220,3 +352,27 @@ export function CsvConnectorUI({
</div>
);
}
interface UnmappedColumnsFooterProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
}
const UnmappedColumnsFooter = ({ sourceFields, mappings }: Readonly<UnmappedColumnsFooterProps>) => {
const { t } = useTranslation();
const claimed = new Set(mappings.map((m) => m.sourceFieldId).filter((id): id is string => Boolean(id)));
const unmapped = sourceFields.filter((f) => !claimed.has(f.id));
if (unmapped.length === 0) return null;
return (
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<p className="font-medium">
{t("workspace.unify.csv_unmapped_columns", {
count: unmapped.length,
columns: unmapped.map((c) => c.name).join(", "),
})}
</p>
<p className="mt-0.5 text-slate-500">{t("workspace.unify.csv_unmapped_columns_explainer")}</p>
</div>
);
};
@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
@@ -32,6 +33,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import {
CSV_HIDDEN_STATIC_MAPPINGS,
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TFormbricksConnectorForm,
@@ -40,7 +42,7 @@ import {
ZFormbricksConnectorForm,
} from "../types";
import {
areAllRequiredFieldsMapped,
areAllRequiredCsvFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
@@ -187,13 +189,26 @@ export const EditConnectorModal = ({
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
if (!requiredCheck.valid) {
toast.error(
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
);
return;
}
setIsUpdating(true);
const protectedIds = ["tenant_id", "source_type"];
const userMappings = mappings.filter((m) => protectedIds.every((id) => m.targetFieldId !== id));
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
surveyMappings: undefined,
fieldMappings: mappings.length > 0 ? mappings : undefined,
fieldMappings,
});
setIsUpdating(false);
handleOpenChange(false);
@@ -220,7 +235,7 @@ export const EditConnectorModal = ({
}
if (connector.type === "csv") {
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredCsvFieldsMapped(mappings).valid;
}
return true;
@@ -1,17 +1,32 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import {
AlertTriangleIcon,
ChevronDownIcon,
ClockIcon,
GripVerticalIcon,
MinusCircleIcon,
PencilIcon,
SparklesIcon,
TextCursorInputIcon,
XIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { TFieldMapping, TSourceField, TTargetField } from "../types";
@@ -250,6 +265,40 @@ interface DroppableTargetFieldProps {
isOver?: boolean;
}
export type TAutoMapState = "high" | "medium" | "low";
interface AutoMappedBadgeProps {
state: TAutoMapState;
sourceColumn?: string;
}
const AUTO_MAP_BADGE_STYLES: Record<TAutoMapState, string> = {
high: "bg-indigo-50 text-indigo-700",
medium: "bg-amber-50 text-amber-800",
low: "bg-orange-100 text-orange-800",
};
export const AutoMappedBadge = ({ state, sourceColumn }: AutoMappedBadgeProps) => {
const { t } = useTranslation();
const isHigh = state === "high";
const Icon = isHigh ? SparklesIcon : AlertTriangleIcon;
const className = cn(
"ml-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
AUTO_MAP_BADGE_STYLES[state]
);
const label = isHigh ? t("workspace.unify.csv_auto_mapped") : t("workspace.unify.csv_auto_mapped_verify");
return (
<TooltipRenderer
tooltipContent={t("workspace.unify.csv_auto_mapped_tooltip", { column: sourceColumn ?? "—" })}>
<span className={className}>
<Icon className="h-3 w-3" />
{label}
</span>
</TooltipRenderer>
);
};
export const DroppableTargetField = ({
field,
mappedSourceField,
@@ -356,3 +405,264 @@ export const DroppableTargetField = ({
</div>
);
};
const SENTINEL = {
COLUMN_PREFIX: "__col__:",
ENUM_PREFIX: "__enum__:",
STATIC_NOW: "__static_now__",
EDIT_FIXED: "__edit_fixed__",
CLEAR: "__clear__",
} as const;
// Section header inside a Select dropdown (e.g. "CSV Columns") — small, uppercase, muted; reads
// as a label rather than a clickable option. Sized to match secondary text (text-xs).
const GROUP_LABEL_CLASS = "px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500";
interface FormTargetFieldProps {
field: TTargetField;
mapping: TFieldMapping | null;
sourceFields: TSourceField[];
/** All current mappings — used to flag columns already mapped to a different target. */
allMappings: TFieldMapping[];
/** Lookup of target-field id → display name, for the "Mapped to: …" indicator. */
targetNameById: Record<string, string>;
onChange: (next: TFieldMapping | null) => void;
autoMapState?: TAutoMapState;
autoMapSourceColumn?: string;
preview?: string;
}
export const FormTargetField = ({
field,
mapping,
sourceFields,
allMappings,
targetNameById,
onChange,
autoMapState,
autoMapSourceColumn,
preview,
}: FormTargetFieldProps) => {
const { t } = useTranslation();
const [isEditingFixed, setIsEditingFixed] = useState(false);
const [draftFixedValue, setDraftFixedValue] = useState("");
const hasMapping = Boolean(mapping?.sourceFieldId || mapping?.staticValue);
const isEnum = field.type === "enum" && Boolean(field.enumValues?.length);
const isTimestamp = field.type === "timestamp";
// For each column, the name of another target it's mapped to (excluding this row's target).
// Used to render a "Mapped to: …" badge on column options so the user can tell at a glance which
// columns are already in use elsewhere.
const otherUsageByColumn = useMemo(() => {
const map: Record<string, string> = {};
for (const m of allMappings) {
if (!m.sourceFieldId) continue;
if (m.targetFieldId === field.id) continue;
map[m.sourceFieldId] = targetNameById[m.targetFieldId] ?? m.targetFieldId;
}
return map;
}, [allMappings, field.id, targetNameById]);
const selectValue = useMemo(() => {
if (mapping?.sourceFieldId) return `${SENTINEL.COLUMN_PREFIX}${mapping.sourceFieldId}`;
if (mapping?.staticValue === "$now") return SENTINEL.STATIC_NOW;
if (isEnum && mapping?.staticValue) return `${SENTINEL.ENUM_PREFIX}${mapping.staticValue}`;
// Fixed value (non-$now, non-enum) maps to the EDIT_FIXED sentinel so the trigger renders the
// EDIT_FIXED item's "Edit fixed value: …" label.
if (mapping?.staticValue !== undefined && mapping?.staticValue !== "") {
return SENTINEL.EDIT_FIXED;
}
return "";
}, [mapping, isEnum]);
const openFixedValueEditor = () => {
setDraftFixedValue(mapping?.staticValue && mapping.staticValue !== "$now" ? mapping.staticValue : "");
setIsEditingFixed(true);
};
const handleSelectChange = (value: string) => {
if (value.startsWith(SENTINEL.COLUMN_PREFIX)) {
onChange({
targetFieldId: field.id,
sourceFieldId: value.slice(SENTINEL.COLUMN_PREFIX.length),
});
setIsEditingFixed(false);
return;
}
if (value === SENTINEL.STATIC_NOW) {
onChange({ targetFieldId: field.id, staticValue: "$now" });
setIsEditingFixed(false);
return;
}
if (value.startsWith(SENTINEL.ENUM_PREFIX)) {
onChange({
targetFieldId: field.id,
staticValue: value.slice(SENTINEL.ENUM_PREFIX.length),
});
setIsEditingFixed(false);
return;
}
if (value === SENTINEL.EDIT_FIXED) {
openFixedValueEditor();
return;
}
if (value === SENTINEL.CLEAR) {
onChange(null);
setIsEditingFixed(false);
}
};
const handleSaveFixed = () => {
const trimmed = draftFixedValue.trim();
if (trimmed) {
onChange({ targetFieldId: field.id, staticValue: trimmed });
} else if (!field.required) {
onChange(null);
}
setIsEditingFixed(false);
};
return (
<div className="rounded-md border border-slate-200 bg-white p-3">
<div className="flex items-baseline gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
{isEnum && <span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>}
{hasMapping && autoMapState && (
<AutoMappedBadge state={autoMapState} sourceColumn={autoMapSourceColumn} />
)}
</div>
<p className="mt-0.5 text-xs text-slate-500">{field.description}</p>
<div className="mt-2">
{isEditingFixed ? (
<div className="flex items-center gap-2">
<Input
autoFocus
value={draftFixedValue}
onChange={(e) => setDraftFixedValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveFixed();
}
if (e.key === "Escape") {
e.preventDefault();
setIsEditingFixed(false);
}
}}
placeholder={t("workspace.unify.set_value")}
className="h-9"
/>
<Button size="sm" onClick={handleSaveFixed}>
{t("common.done")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setIsEditingFixed(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<div className="flex-1">
<Select value={selectValue || undefined} onValueChange={handleSelectChange}>
<SelectTrigger className="h-9 w-full bg-white">
<SelectValue
placeholder={
isEnum
? t("workspace.unify.select_a_value")
: t("workspace.unify.csv_pick_column_placeholder")
}
/>
</SelectTrigger>
<SelectContent>
{isEnum && field.enumValues ? (
field.enumValues.map((enumValue) => (
<SelectItem key={enumValue} value={`${SENTINEL.ENUM_PREFIX}${enumValue}`}>
{enumValue}
</SelectItem>
))
) : (
<>
{sourceFields.length > 0 && (
<SelectGroup>
<SelectLabel className={GROUP_LABEL_CLASS}>
{t("workspace.unify.csv_columns")}
</SelectLabel>
{sourceFields.map((column) => {
const otherUsage = otherUsageByColumn[column.id];
return (
<SelectItem key={column.id} value={`${SENTINEL.COLUMN_PREFIX}${column.id}`}>
<span className="flex w-full items-center gap-2">
<span className="text-slate-900">{column.name}</span>
{otherUsage && (
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-xs font-normal text-slate-500">
{t("workspace.unify.csv_column_used_by", { target: otherUsage })}
</span>
)}
</span>
</SelectItem>
);
})}
</SelectGroup>
)}
{isTimestamp && (
<>
<SelectSeparator />
<SelectItem value={SENTINEL.STATIC_NOW}>
<span className="inline-flex items-center gap-2 text-slate-900">
<ClockIcon className="h-3.5 w-3.5" />
{t("workspace.unify.csv_now_label")}
</span>
</SelectItem>
</>
)}
<SelectSeparator />
<SelectItem value={SENTINEL.EDIT_FIXED}>
<span className="inline-flex items-center gap-2 text-slate-900">
<TextCursorInputIcon className="h-3.5 w-3.5" />
{mapping?.staticValue && mapping.staticValue !== "$now"
? t("workspace.unify.csv_fixed_value_label", {
value: truncate(mapping.staticValue, 40),
})
: t("workspace.unify.csv_fixed_value_action")}
</span>
</SelectItem>
{!field.required && hasMapping && (
<>
<SelectSeparator />
<SelectItem value={SENTINEL.CLEAR}>
<span className="inline-flex items-center gap-2 text-slate-900">
<MinusCircleIcon className="h-3.5 w-3.5" />
{t("workspace.unify.dont_include")}
</span>
</SelectItem>
</>
)}
</>
)}
</SelectContent>
</Select>
</div>
{!isEnum && mapping?.staticValue && mapping.staticValue !== "$now" && (
<Button
size="sm"
variant="secondary"
onClick={openFixedValueEditor}
aria-label={t("workspace.unify.csv_fixed_value_action")}
className="shrink-0">
<PencilIcon className="h-3.5 w-3.5" />
{t("common.edit")}
</Button>
)}
</div>
)}
</div>
{preview && <p className="mt-2 text-xs text-slate-500">{preview}</p>}
</div>
);
};
const truncate = (value: string, max: number): string =>
value.length > max ? `${value.slice(0, max - 1)}` : value;
@@ -1,26 +1,46 @@
"use client";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { useState } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import {
CSV_FIELD_GROUPS,
CSV_TARGET_FIELDS,
FEEDBACK_RECORD_FIELDS,
TFieldMapping,
TSourceField,
TTargetField,
} from "../types";
import { TMappingConfidence, routeResponseValueTarget } from "../utils";
import { DraggableSourceField, DroppableTargetField, FormTargetField, TAutoMapState } from "./mapping-field";
interface MappingUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
connectorType: TConnectorType;
confidenceByTargetId?: Record<string, TMappingConfidence>;
sampleRow?: Record<string, string>;
}
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
const toAutoMapState = (confidence?: TMappingConfidence): TAutoMapState | undefined => {
if (confidence === "high" || confidence === "medium" || confidence === "low") return confidence;
return undefined;
};
export function MappingUI({
sourceFields,
mappings,
onMappingsChange,
connectorType,
confidenceByTargetId,
sampleRow,
}: MappingUIProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
@@ -65,22 +85,31 @@ export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorT
const activeField = activeId ? getSourceFieldById(activeId) : null;
if (connectorType === "csv") {
return (
<CsvMappingForm
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
confidenceByTargetId={confidenceByTargetId}
sampleRow={sampleRow}
/>
);
}
// Survey (and other future) connectors keep the DnD layout.
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">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")}
</h4>
<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">
{connectorType === "csv"
? t("workspace.unify.click_load_sample_csv")
: t("workspace.unify.no_source_fields_loaded")}
</p>
<p className="text-sm text-slate-500">{t("workspace.unify.no_source_fields_loaded")}</p>
</div>
) : (
<div className="space-y-2">
@@ -91,42 +120,38 @@ export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorT
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">
{t("workspace.unify.feedback_record_fields")}
</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t("workspace.unify.required")}
</p>
{requiredFields.map((field) => (
{requiredFields.map((targetField) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
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>
{/* Optional Fields */}
<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((field) => (
{optionalFields.map((targetField) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
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>
@@ -144,3 +169,153 @@ export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorT
</DndContext>
);
}
interface CsvMappingFormProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
confidenceByTargetId?: Record<string, TMappingConfidence>;
sampleRow?: Record<string, string>;
}
const CsvMappingForm = ({
sourceFields,
mappings,
onMappingsChange,
confidenceByTargetId,
sampleRow,
}: CsvMappingFormProps) => {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
const fieldsById = new Map(CSV_TARGET_FIELDS.map((f) => [f.id, f]));
const targetNameById = useMemo(() => Object.fromEntries(CSV_TARGET_FIELDS.map((f) => [f.id, f.name])), []);
const upsertMapping = (next: TFieldMapping) => {
const filtered = mappings.filter((m) => m.targetFieldId !== next.targetFieldId);
onMappingsChange([...filtered, next]);
};
const removeMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleChange = (targetFieldId: string, next: TFieldMapping | null) => {
if (next === null) removeMapping(targetFieldId);
else upsertMapping(next);
};
const responseValueMapping = mappings.find((m) => m.targetFieldId === "response_value");
const fieldTypeMapping = mappings.find((m) => m.targetFieldId === "field_type");
const responseValuePreview = computeResponseValuePreview({
responseValueMapping,
fieldTypeMapping,
sampleRow,
sourceFields,
t,
});
const renderField = (target: TTargetField) => {
const mapping = mappings.find((m) => m.targetFieldId === target.id) ?? null;
const autoMapState = toAutoMapState(confidenceByTargetId?.[target.id]);
const sourceColumnName = mapping?.sourceFieldId
? sourceFields.find((s) => s.id === mapping.sourceFieldId)?.name
: undefined;
const isResponseValue = target.id === "response_value";
return (
<FormTargetField
key={target.id}
field={target}
mapping={mapping}
sourceFields={sourceFields}
allMappings={mappings}
targetNameById={targetNameById}
onChange={(next) => handleChange(target.id, next)}
autoMapState={autoMapState}
autoMapSourceColumn={sourceColumnName}
preview={isResponseValue ? responseValuePreview : undefined}
/>
);
};
const renderGroup = (ids: readonly string[]) =>
ids
.map((id) => fieldsById.get(id))
.filter((f): f is TTargetField => Boolean(f))
.map(renderField);
return (
<div className="space-y-6">
<div className="space-y-2">
<div>
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_basic_required")}</p>
<p className="text-xs text-slate-500">{t("workspace.unify.csv_basic_required_hint")}</p>
</div>
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.basic)}</div>
</div>
<div className="space-y-2">
<div>
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_source_context")}</p>
<p className="text-xs text-slate-500">{t("workspace.unify.csv_source_context_hint")}</p>
</div>
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.sourceContext)}</div>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
className="flex w-full items-start gap-2 rounded text-left">
<span className="mt-0.5 text-slate-500">
{advancedOpen ? (
<ChevronDownIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</span>
<span>
<span className="block text-sm font-semibold text-slate-800">
{t("workspace.unify.csv_advanced")}
</span>
<span className="block text-xs text-slate-500">{t("workspace.unify.csv_advanced_hint")}</span>
</span>
</button>
{advancedOpen && <div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.advanced)}</div>}
</div>
</div>
);
};
interface ComputePreviewArgs {
responseValueMapping: TFieldMapping | undefined;
fieldTypeMapping: TFieldMapping | undefined;
sampleRow: Record<string, string> | undefined;
sourceFields: TSourceField[];
t: ReturnType<typeof useTranslation>["t"];
}
const computeResponseValuePreview = ({
responseValueMapping,
fieldTypeMapping,
sampleRow,
sourceFields,
t,
}: ComputePreviewArgs): string | undefined => {
if (!responseValueMapping?.sourceFieldId) return undefined;
const fieldTypeRaw = fieldTypeMapping?.staticValue ?? "";
const parsed = ZHubFieldType.safeParse(fieldTypeRaw);
if (!parsed.success) return undefined;
const fieldType: THubFieldType = parsed.data;
const target = routeResponseValueTarget(fieldType);
const sample =
sampleRow?.[responseValueMapping.sourceFieldId] ??
sourceFields.find((f) => f.id === responseValueMapping.sourceFieldId)?.sampleValue ??
"";
const targetLabel = target.replace("value_", "");
return t("workspace.unify.csv_response_preview", {
sample,
target: targetLabel.charAt(0).toUpperCase() + targetLabel.slice(1),
});
};
@@ -175,6 +175,56 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
},
];
// Synthetic CSV-only target field. The CSV mapping UI exposes a single `response_value` control
// instead of the four `value_*` targets; csv-transform.ts routes it to the correct value_* based
// on the row's resolved field_type.
export const CSV_RESPONSE_VALUE_TARGET: TTargetField = {
id: "response_value",
name: "Response",
type: "string",
required: true,
description:
"The user's actual answer or value. We'll store it in the right format (text, number, boolean, or date) based on Field Type.",
};
// Target fields visible in the CSV mapping UI. Excludes tenant_id (backend-backfilled),
// source_type (static "csv", hidden) and the four value_* targets (replaced by response_value).
const CSV_HIDDEN_TARGET_IDS = [
"tenant_id",
"source_type",
"value_text",
"value_number",
"value_boolean",
"value_date",
];
export const CSV_TARGET_FIELDS: TTargetField[] = [
...FEEDBACK_RECORD_FIELDS.filter((f) => CSV_HIDDEN_TARGET_IDS.every((id) => f.id !== id)),
CSV_RESPONSE_VALUE_TARGET,
];
export const CSV_FIELD_GROUPS = {
basic: ["collected_at", "field_id", "field_label", "field_type", "response_value"],
sourceContext: ["source_id", "source_name"],
advanced: [
"submission_id",
"field_group_id",
"field_group_label",
"language",
"user_identifier",
"metadata",
],
} as const;
// Mappings always merged into a CSV connector's persisted mappings on save. Keeps internal
// fields off the wire from the user.
export const CSV_HIDDEN_STATIC_MAPPINGS: TFieldMapping[] = [
{ targetFieldId: "source_type", staticValue: "csv" },
];
// Fields the CSV UI requires the user to resolve before saving. collected_at is excluded
// because it falls back to "$now" automatically.
export const CSV_REQUIRED_UI_FIELDS = ["field_id", "field_label", "field_type", "response_value"];
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
export const MAX_CSV_VALUES = {
@@ -1,10 +1,15 @@
import { describe, expect, test } from "vitest";
import { ZHubFieldType } from "@formbricks/types/connector";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
areAllRequiredFieldsMapped,
areAllRequiredCsvFieldsMapped,
autoMapCsvSourceFields,
getConnectorOptions,
inferFieldType,
isConnectorNameValid,
parseCSVColumnsToFields,
routeResponseValueTarget,
titleizeFromFileName,
toggleQuestionId,
validateCsvFile,
} from "./utils";
@@ -146,59 +151,214 @@ describe("isConnectorNameValid", () => {
});
});
describe("areAllRequiredFieldsMapped", () => {
const requiredMappings: TFieldMapping[] = [
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", staticValue: "csv" },
describe("areAllRequiredCsvFieldsMapped", () => {
const fullMappings: TFieldMapping[] = [
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_label", sourceFieldId: "label" },
{ targetFieldId: "field_type", staticValue: "text" },
{ targetFieldId: "response_value", sourceFieldId: "answer" },
];
test("returns true when all required fields have a sourceFieldId or staticValue", () => {
expect(areAllRequiredFieldsMapped(requiredMappings)).toBe(true);
test("returns valid=true and missing=[] when every required UI field is resolved", () => {
expect(areAllRequiredCsvFieldsMapped(fullMappings)).toEqual({ valid: true, missing: [] });
});
test("returns false when a required field is missing entirely", () => {
const missing = requiredMappings.slice(0, 3);
expect(areAllRequiredFieldsMapped(missing)).toBe(false);
});
test.each(["field_id", "field_label", "field_type", "response_value"])(
"returns valid=false and lists %s when missing",
(missingId) => {
const partial = fullMappings.filter((m) => m.targetFieldId !== missingId);
const result = areAllRequiredCsvFieldsMapped(partial);
expect(result.valid).toBe(false);
expect(result.missing).toContain(missingId);
}
);
test("returns false when a required mapping has neither sourceFieldId nor staticValue", () => {
const incomplete: TFieldMapping[] = [...requiredMappings.slice(0, 3), { targetFieldId: "field_type" }];
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
});
test("ignores mappings for non-required target fields", () => {
const withOptionals: TFieldMapping[] = [
...requiredMappings,
{ targetFieldId: "tenant_id", sourceFieldId: "tenant" },
{ targetFieldId: "unknown_field", sourceFieldId: "anything" },
];
expect(areAllRequiredFieldsMapped(withOptionals)).toBe(true);
});
test("returns false for empty mappings array", () => {
expect(areAllRequiredFieldsMapped([])).toBe(false);
});
test("treats empty staticValue and missing sourceFieldId as unmapped", () => {
test("treats whitespace-only staticValue as unmapped", () => {
const incomplete: TFieldMapping[] = [
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", sourceFieldId: "", staticValue: "" },
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_type", staticValue: "text" },
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
{ targetFieldId: "field_type", staticValue: " " },
];
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
expect(areAllRequiredCsvFieldsMapped(incomplete).missing).toContain("field_type");
});
test("counts required field as mapped when only staticValue is set", () => {
const onlyStatic: TFieldMapping[] = [
{ targetFieldId: "collected_at", staticValue: "2026-01-01" },
{ targetFieldId: "source_type", staticValue: "csv" },
{ targetFieldId: "field_id", staticValue: "id" },
{ targetFieldId: "field_type", staticValue: "text" },
];
expect(areAllRequiredFieldsMapped(onlyStatic)).toBe(true);
test("does not require collected_at (defaults to $now)", () => {
expect(areAllRequiredCsvFieldsMapped(fullMappings).missing).not.toContain("collected_at");
});
});
describe("titleizeFromFileName", () => {
test.each([
["feedback.csv", "Feedback"],
["q1-2026-survey.csv", "Q1 2026 Survey"],
["customer_feedback_data.csv", "Customer Feedback Data"],
["Mixed Case File.CSV", "Mixed Case File"],
["nps results", "Nps Results"],
["", ""],
])("titleizes %s to %s", (input, expected) => {
expect(titleizeFromFileName(input)).toBe(expected);
});
});
describe("inferFieldType", () => {
test("detects integer numbers from samples", () => {
expect(inferFieldType({ samples: ["3", "5", "10"] })).toBe("number");
});
test("detects floating-point numbers from samples", () => {
expect(inferFieldType({ samples: ["3.14", "-2.5"] })).toBe("number");
});
test("detects booleans from samples", () => {
expect(inferFieldType({ samples: ["true", "false", "yes", "no"] })).toBe("boolean");
});
test("detects ISO dates from samples", () => {
expect(inferFieldType({ samples: ["2026-01-01", "2026-02-15T10:00:00Z"] })).toBe("date");
});
test("falls back to text for arbitrary strings", () => {
expect(inferFieldType({ samples: ["hello", "world"] })).toBe("text");
});
test("returns text for empty samples", () => {
expect(inferFieldType({ samples: [] })).toBe("text");
expect(inferFieldType({ samples: ["", " "] })).toBe("text");
});
test("name hint wins over sample sniff (rating column with garbage samples)", () => {
expect(inferFieldType({ columnName: "rating", samples: ["asdf", "qwer"] })).toBe("rating");
});
test.each([
["nps", "nps"],
["nps_score", "nps"],
["csat", "csat"],
["ces", "ces"],
["stars", "rating"],
["score", "rating"],
["comment", "text"],
["category", "categorical"],
["is_promoter", "boolean"],
["has_responded", "boolean"],
["submitted_at", "date"],
])("name hint %s → %s", (columnName, expected) => {
expect(inferFieldType({ columnName, samples: [] })).toBe(expected);
});
test("name with no hint falls back to sample sniffing", () => {
expect(inferFieldType({ columnName: "anonymous", samples: ["42"] })).toBe("number");
});
});
describe("routeResponseValueTarget", () => {
test.each([
["text", "value_text"],
["categorical", "value_text"],
["number", "value_number"],
["nps", "value_number"],
["csat", "value_number"],
["ces", "value_number"],
["rating", "value_number"],
["boolean", "value_boolean"],
["date", "value_date"],
] as const)("routes %s to %s", (fieldType, expected) => {
expect(routeResponseValueTarget(fieldType)).toBe(expected);
});
test("covers every THubFieldType enum value", () => {
for (const fieldType of ZHubFieldType.options) {
// Throws if any enum member is unhandled.
expect(() => routeResponseValueTarget(fieldType)).not.toThrow();
}
});
});
describe("autoMapCsvSourceFields", () => {
const buildSourceFields = (names: string[]): TSourceField[] =>
names.map((name) => ({ id: name, name, type: "string", sampleValue: "" }));
test("maps timestamp column to collected_at with high confidence", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["timestamp", "answer"]),
sampleRow: { timestamp: "2026-01-01", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
expect(mapping?.sourceFieldId).toBe("timestamp");
expect(result.confidence.collected_at).toBe("high");
});
test("falls back to $now when no timestamp column is present", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "answer"]),
sampleRow: { question: "q1", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
expect(mapping?.staticValue).toBe("$now");
expect(result.confidence.collected_at).toBe("high");
});
test("maps email to user_identifier with medium confidence", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["email", "answer"]),
sampleRow: { email: "x@y.com", answer: "yes" },
fileName: "feedback.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "user_identifier");
expect(mapping?.sourceFieldId).toBe("email");
expect(result.confidence.user_identifier).toBe("medium");
});
test("prepopulates source_name from titleized filename", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "answer"]),
sampleRow: { question: "q1", answer: "yes" },
fileName: "Q1-2026-survey.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
expect(mapping?.staticValue).toBe("Q1 2026 Survey");
expect(result.confidence.source_name).toBe("high");
});
test("ambiguous column claimed by highest-confidence target", () => {
// 'id' matches both field_id (medium) and... only field_id. Add a high-confidence
// alternative claim to verify higher confidence wins.
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question_id", "id"]),
sampleRow: { question_id: "q1", id: "u1" },
fileName: "x.csv",
});
const fieldIdMapping = result.mappings.find((m) => m.targetFieldId === "field_id");
expect(fieldIdMapping?.sourceFieldId).toBe("question_id");
expect(result.confidence.field_id).toBe("high");
});
test("infers field_type as static via sample sniffing when name has no hint", () => {
// "result" doesn't match any FIELD_TYPE_NAME_HINTS pattern, but it does match the response_value
// medium alias /^(score|rating|feedback)$/i? No — let's use a generic name. Use "result"
// (matches nothing in CSV_COLUMN_ALIASES high tier; falls through to fuzzy substring as low).
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "value"]),
// "value" matches response_value high-confidence; sample is numeric.
sampleRow: { question: "q1", value: "42" },
fileName: "x.csv",
});
const fieldTypeMapping = result.mappings.find((m) => m.targetFieldId === "field_type");
// Column name "value" has no FIELD_TYPE_NAME_HINTS match, so we fall back to sample sniffing.
expect(fieldTypeMapping?.staticValue).toBe("number");
expect(result.confidence.field_type).toBe("medium");
});
test("infers field_type as 'rating' (high confidence) when response_value column is named 'rating'", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["question", "rating"]),
sampleRow: { question: "q1", rating: "garbage" },
fileName: "x.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "field_type");
expect(mapping?.staticValue).toBe("rating");
expect(result.confidence.field_type).toBe("high");
});
});
@@ -1,6 +1,13 @@
import { TFunction } from "i18next";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
CSV_REQUIRED_UI_FIELDS,
CSV_TARGET_FIELDS,
FEEDBACK_RECORD_FIELDS,
MAX_CSV_VALUES,
TFieldMapping,
TSourceField,
} from "./types";
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
@@ -92,24 +99,6 @@ export const validateEnumMappings = (
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
export const toggleQuestionId = (currentSelection: string[], questionId: string): string[] => {
return currentSelection.includes(questionId)
? currentSelection.filter((id) => id !== questionId)
@@ -131,3 +120,267 @@ export const validateCsvFile = (
}
return { valid: true };
};
export type TMappingConfidence = "high" | "medium" | "low";
// Convert a filename like "q1-2026_survey-results.csv" into "Q1 2026 Survey Results".
export const titleizeFromFileName = (fileName: string): string => {
const base = fileName.replace(/\.csv$/i, "");
const words = base.split(/[_\-\s]+/).filter(Boolean);
if (words.length === 0) return base;
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
};
// Alias dictionary keyed by CSV target field id. Match in priority order: high → medium → fuzzy
// substring (low). Patterns are case-insensitive and applied to header names.
export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp[] }> = {
collected_at: {
high: [/^(timestamp|collected_at|submitted_at)$/i],
medium: [/^(created_at|date|time|datetime)$/i],
},
field_id: {
high: [/^(field_id|question_id|q_id)$/i],
medium: [/^(id|key)$/i],
},
field_label: {
high: [/^(field_label|question|label|question_text)$/i],
medium: [/^(name|title|prompt)$/i],
},
// field_type is intentionally not aliased to a CSV column. The UI exposes it as a single static
// enum picker (one type per CSV); we infer the value from FIELD_TYPE_NAME_HINTS + samples.
response_value: {
high: [/^(response|answer|value|response_value)$/i],
medium: [/^(score|rating|feedback)$/i],
},
source_id: {
high: [/^(source_id|survey_id|form_id)$/i],
medium: [],
},
source_name: {
high: [/^source_name$/i],
medium: [],
},
language: {
high: [/^(language|lang|locale)$/i],
medium: [],
},
user_identifier: {
high: [/^(user_id|user_identifier|customer_id)$/i],
medium: [/^(email|user|customer)$/i],
},
metadata: {
high: [/^metadata$/i],
medium: [],
},
};
// Column-name hints used to pick a `field_type` when the user maps a column whose name strongly
// implies a question type (e.g. "rating", "nps", "is_promoter"). Checked before sample sniffing.
export const FIELD_TYPE_NAME_HINTS: Array<{ pattern: RegExp; type: THubFieldType }> = [
{ pattern: /^(rating|stars|score)$/i, type: "rating" },
{ pattern: /^(nps|nps_score|net_promoter)$/i, type: "nps" },
{ pattern: /^csat$/i, type: "csat" },
{ pattern: /^ces$/i, type: "ces" },
{ pattern: /^(number|count|amount|qty|quantity)$/i, type: "number" },
{ pattern: /^(comment|feedback|answer|response|text)$/i, type: "text" },
{ pattern: /^(category|choice|option|select)$/i, type: "categorical" },
{ pattern: /^(is_|has_|did_)/i, type: "boolean" },
{ pattern: /^(date|submitted_at|completed_at)$/i, type: "date" },
];
// Infer a likely THubFieldType from a column. Tries name hints first (more reliable), then falls
// back to sniffing sample values, then defaults to "text".
export const inferFieldType = ({
columnName,
samples,
}: {
columnName?: string;
samples: string[];
}): THubFieldType => {
if (columnName) {
for (const hint of FIELD_TYPE_NAME_HINTS) {
if (hint.pattern.test(columnName)) return hint.type;
}
}
const cleaned = samples.map((s) => s?.trim()).filter((s): s is string => Boolean(s));
if (cleaned.length === 0) return "text";
const isBool = cleaned.every((s) => /^(true|false|yes|no|0|1)$/i.test(s));
if (isBool) return "boolean";
const isNumber = cleaned.every((s) => !Number.isNaN(Number.parseFloat(s)) && /^-?\d+(\.\d+)?$/.test(s));
if (isNumber) return "number";
const isDate = cleaned.every((s) => !Number.isNaN(new Date(s).getTime()));
if (isDate) return "date";
return "text";
};
// Centralized, exhaustive routing from THubFieldType to the underlying value_* target. Throws on
// unknown values so we fail closed if THubFieldType ever gains a member without a routing decision.
export const routeResponseValueTarget = (
fieldType: THubFieldType
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "text":
case "categorical":
return "value_text";
case "number":
case "nps":
case "csat":
case "ces":
case "rating":
return "value_number";
case "boolean":
return "value_boolean";
case "date":
return "value_date";
default: {
const _exhaustive: never = fieldType;
throw new Error(`Unhandled field_type for response_value routing: ${String(_exhaustive)}`);
}
}
};
interface TAutoMapResult {
mappings: TFieldMapping[];
confidence: Record<string, TMappingConfidence>;
}
interface TAutoMapInput {
sourceFields: TSourceField[];
sampleRow: Record<string, string>;
fileName: string;
}
const findBestSourceMatch = (
targetId: string,
sourceFields: TSourceField[]
): { sourceField: TSourceField; confidence: TMappingConfidence } | null => {
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) return null;
for (const pattern of aliases.high) {
const match = sourceFields.find((f) => pattern.test(f.name));
if (match) return { sourceField: match, confidence: "high" };
}
for (const pattern of aliases.medium) {
const match = sourceFields.find((f) => pattern.test(f.name));
if (match) return { sourceField: match, confidence: "medium" };
}
// Fuzzy substring fallback: target id token contained in header.
const idToken = targetId.split("_").pop() ?? targetId;
const fuzzy = sourceFields.find((f) => f.name.toLowerCase().includes(idToken.toLowerCase()));
if (fuzzy) return { sourceField: fuzzy, confidence: "low" };
return null;
};
// Auto-maps source columns onto CSV target fields based on header aliases, the filename, and a
// sample row. Resolves conflicts (one source matched by multiple targets) by giving the source to
// the highest-confidence target; later targets fall through to lower-confidence matches or remain
// unmapped.
export const autoMapCsvSourceFields = ({
sourceFields,
sampleRow,
fileName,
}: TAutoMapInput): TAutoMapResult => {
const mappings: TFieldMapping[] = [];
const confidence: Record<string, TMappingConfidence> = {};
const claimedSources = new Set<string>();
// Match strength order — higher confidence claims a source first.
const orderedTargets = CSV_TARGET_FIELDS.map((t) => t.id);
// First pass: high-confidence matches.
for (const targetId of orderedTargets) {
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) continue;
for (const pattern of aliases.high) {
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
if (match) {
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
confidence[targetId] = "high";
claimedSources.add(match.id);
break;
}
}
}
// Second pass: medium-confidence matches for still-unmapped targets.
for (const targetId of orderedTargets) {
if (confidence[targetId]) continue;
const aliases = CSV_COLUMN_ALIASES[targetId];
if (!aliases) continue;
for (const pattern of aliases.medium) {
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
if (match) {
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
confidence[targetId] = "medium";
claimedSources.add(match.id);
break;
}
}
}
// Third pass: low-confidence fuzzy substring matches (best effort).
for (const targetId of orderedTargets) {
if (confidence[targetId]) continue;
const remaining = sourceFields.filter((f) => !claimedSources.has(f.id));
const guess = findBestSourceMatch(targetId, remaining);
if (guess && guess.confidence === "low") {
mappings.push({ targetFieldId: targetId, sourceFieldId: guess.sourceField.id });
confidence[targetId] = "low";
claimedSources.add(guess.sourceField.id);
}
}
// collected_at: if no column matched, default to "$now".
if (!confidence.collected_at) {
mappings.push({ targetFieldId: "collected_at", staticValue: "$now" });
confidence.collected_at = "high";
}
// source_name: prepopulate from filename (titleized) if not column-mapped.
if (!confidence.source_name) {
mappings.push({ targetFieldId: "source_name", staticValue: titleizeFromFileName(fileName) });
confidence.source_name = "high";
}
// field_type: if still unmapped, infer from the response_value column's name and sample. Name
// hints (e.g. column "rating" → field_type "rating") win over sample-based sniffing.
if (!confidence.field_type) {
const responseMapping = mappings.find((m) => m.targetFieldId === "response_value");
if (responseMapping?.sourceFieldId) {
const sourceField = sourceFields.find((f) => f.id === responseMapping.sourceFieldId);
const inferred = inferFieldType({
columnName: sourceField?.name,
samples: [sampleRow[responseMapping.sourceFieldId] ?? ""],
});
mappings.push({ targetFieldId: "field_type", staticValue: inferred });
// Name-hint matches deserve higher confidence than blind sample sniffing.
const nameHinted = sourceField?.name
? FIELD_TYPE_NAME_HINTS.some((h) => h.pattern.test(sourceField.name))
: false;
confidence.field_type = nameHinted ? "high" : "medium";
}
}
return { mappings, confidence };
};
// CSV-specific validator: confirms every UI-required field is resolved (column mapping or
// non-empty static value). Returns the missing fields so the UI can render a useful error.
export const areAllRequiredCsvFieldsMapped = (
mappings: TFieldMapping[]
): { valid: boolean; missing: string[] } => {
const missing: string[] = [];
for (const requiredId of CSV_REQUIRED_UI_FIELDS) {
const mapping = mappings.find((m) => m.targetFieldId === requiredId);
const resolved = Boolean(mapping?.sourceFieldId || mapping?.staticValue?.trim());
if (!resolved) missing.push(requiredId);
}
return { valid: missing.length === 0, missing };
};
+4 -1
View File
@@ -23,7 +23,9 @@ export const ZHubFieldType = z.enum([
]);
export type THubFieldType = z.infer<typeof ZHubFieldType>;
// Hub target fields for mapping
// Hub target fields for mapping.
// `response_value` is a CSV-only synthetic id stored in ConnectorFieldMapping; csv-transform.ts
// resolves it to the appropriate value_* target before any Hub write — the Hub never sees it.
export const ZHubTargetField = z.enum([
"collected_at",
"source_type",
@@ -43,6 +45,7 @@ export const ZHubTargetField = z.enum([
"language",
"user_identifier",
"submission_id",
"response_value",
]);
export type THubTargetField = z.infer<typeof ZHubTargetField>;