mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-21 11:49:32 -05:00
revamp CSV mapping UI, with field descriptors
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user