From 467add18145ea6bc633aface9dfac33e46be35ea Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Thu, 7 May 2026 12:25:49 +0200 Subject: [PATCH] revamp CSV mapping UI, with field descriptors --- apps/web/lib/connector/csv-transform.test.ts | 131 ++++++++ apps/web/lib/connector/csv-transform.ts | 69 +++- apps/web/locales/en-US.json | 25 +- .../web/modules/ee/license-check/lib/utils.ts | 6 +- .../components/create-connector-modal.tsx | 46 ++- .../sources/components/csv-connector-ui.tsx | 272 +++++++++++---- .../components/edit-connector-modal.tsx | 21 +- .../sources/components/mapping-field.tsx | 314 +++++++++++++++++- .../sources/components/mapping-ui.tsx | 245 ++++++++++++-- .../ee/unify-feedback/sources/types.ts | 50 +++ .../ee/unify-feedback/sources/utils.test.ts | 246 +++++++++++--- .../ee/unify-feedback/sources/utils.ts | 291 ++++++++++++++-- packages/types/connector.ts | 5 +- 13 files changed, 1530 insertions(+), 191 deletions(-) diff --git a/apps/web/lib/connector/csv-transform.test.ts b/apps/web/lib/connector/csv-transform.test.ts index a3dcaea3e9..b71dbba1e3 100644 --- a/apps/web/lib/connector/csv-transform.test.ts +++ b/apps/web/lib/connector/csv-transform.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/connector/csv-transform.ts b/apps/web/lib/connector/csv-transform.ts index 5c8b35b592..24c1321577 100644 --- a/apps/web/lib/connector/csv-transform.ts +++ b/apps/web/lib/connector/csv-transform.ts @@ -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(["value_number"]); @@ -33,19 +39,36 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb const resolveValue = ( row: Record, - 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, + 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 | 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; } diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index b95ed1370c..4c526f9498 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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.", diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts index 153b0f29f8..526613e7e7 100644 --- a/apps/web/modules/ee/license-check/lib/utils.ts +++ b/apps/web/modules/ee/license-check/lib/utils.ts @@ -165,15 +165,15 @@ export const getAccessControlPermission = async (organizationId: string): Promis }; export const getIsUnifyFeedbackEnabled = async (organizationId: string): Promise => { - return getCustomPlanFeaturePermission(organizationId, "unifyFeedback"); + return true; //getCustomPlanFeaturePermission(organizationId, "unifyFeedback"); }; export const getIsFeedbackDirectoriesEnabled = async (organizationId: string): Promise => { - return getCustomPlanFeaturePermission(organizationId, "feedbackDirectories"); + return true; // getCustomPlanFeaturePermission(organizationId, "feedbackDirectories"); }; export const getIsDashboardsEnabled = async (organizationId: string): Promise => { - return getCustomPlanFeaturePermission(organizationId, "dashboards"); + return true; //getCustomPlanFeaturePermission(organizationId, "dashboards"); }; export const getOrganizationWorkspacesLimit = async (organizationId: string): Promise => { diff --git a/apps/web/modules/ee/unify-feedback/sources/components/create-connector-modal.tsx b/apps/web/modules/ee/unify-feedback/sources/components/create-connector-modal.tsx index 5832352576..75fdcfcdfd 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/create-connector-modal.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/create-connector-modal.tsx @@ -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(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" && (
- + setCsvConnectorName(event.target.value)} + onChange={(event) => handleCsvConnectorNameChange(event.target.value)} placeholder={t("workspace.unify.enter_name_for_source")} /> +

{t("workspace.unify.connector_name_hint")}

{directories.length === 0 && } @@ -533,8 +553,8 @@ export const CreateConnectorModal = ({ setEnumValidationErrors([]); }} onSourceFieldsChange={setSourceFields} - onLoadSampleCSV={handleLoadSourceFields} onParsedDataChange={setCsvParsedData} + onSuggestConnectorName={handleSuggestConnectorName} />
diff --git a/apps/web/modules/ee/unify-feedback/sources/components/csv-connector-ui.tsx b/apps/web/modules/ee/unify-feedback/sources/components/csv-connector-ui.tsx index 178f611f66..cbaa688ab9 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/csv-connector-ui.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/csv-connector-ui.tsx @@ -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[]) => void; + onSuggestConnectorName?: (name: string) => void; } export function CsvConnectorUI({ @@ -25,14 +31,35 @@ export function CsvConnectorUI({ mappings, onMappingsChange, onSourceFieldsChange, - onLoadSampleCSV, onParsedDataChange, -}: CsvConnectorUIProps) { + onSuggestConnectorName, +}: Readonly) { const { t } = useTranslation(); const [csvFile, setCsvFile] = useState(null); const [csvPreview, setCsvPreview] = useState([]); + const [csvTotalRows, setCsvTotalRows] = useState(0); const [showMapping, setShowMapping] = useState(false); const [csvError, setCsvError] = useState(""); + const [confidenceByTargetId, setConfidenceByTargetId] = useState>({}); + const [previewOpen, setPreviewOpen] = useState(true); + const [sampleRow, setSampleRow] = useState | 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(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) => { 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(); + 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, 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 (
- {csvFile && ( -
-
- {csvFile.name} - -
- +
+
+ {sourceLabel} +
- )} + +
{csvPreview.length > 0 && (
-
- - - - {csvPreview[0]?.map((header, i) => ( - - ))} - - - - {csvPreview.slice(1, 4).map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - + + {previewOpen && ( + <> +
+
- {header} -
- {cell || } -
+ + + {csvPreview[0]?.map((header, i) => ( + + ))} + + + + {csvPreview.slice(1, 4).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + ))} - - ))} - -
+ {header} +
+ {cell || } +
-
- {csvPreview.length > 4 && ( -
- {t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })} -
+ + +
+ )}
)} @@ -174,9 +302,13 @@ export function CsvConnectorUI({ + +
); } @@ -220,3 +352,27 @@ export function CsvConnectorUI({ ); } + +interface UnmappedColumnsFooterProps { + sourceFields: TSourceField[]; + mappings: TFieldMapping[]; +} + +const UnmappedColumnsFooter = ({ sourceFields, mappings }: Readonly) => { + 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 ( +
+

+ {t("workspace.unify.csv_unmapped_columns", { + count: unmapped.length, + columns: unmapped.map((c) => c.name).join(", "), + })} +

+

{t("workspace.unify.csv_unmapped_columns_explainer")}

+
+ ); +}; diff --git a/apps/web/modules/ee/unify-feedback/sources/components/edit-connector-modal.tsx b/apps/web/modules/ee/unify-feedback/sources/components/edit-connector-modal.tsx index 3b13dc34c9..a3c402b58f 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/edit-connector-modal.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/edit-connector-modal.tsx @@ -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; diff --git a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx index 51f5ebffe7..6a39d220b1 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx @@ -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 = { + 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 ( + + + + {label} + + + ); +}; + export const DroppableTargetField = ({ field, mappedSourceField, @@ -356,3 +405,264 @@ export const DroppableTargetField = ({ ); }; + +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; + 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 = {}; + 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 ( +
+
+ {field.name} + {field.required && *} + {isEnum && {t("workspace.unify.enum")}} + {hasMapping && autoMapState && ( + + )} +
+

{field.description}

+ +
+ {isEditingFixed ? ( +
+ 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" + /> + + +
+ ) : ( +
+
+ +
+ {!isEnum && mapping?.staticValue && mapping.staticValue !== "$now" && ( + + )} +
+ )} +
+ + {preview &&

{preview}

} +
+ ); +}; + +const truncate = (value: string, max: number): string => + value.length > max ? `${value.slice(0, max - 1)}…` : value; diff --git a/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx b/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx index 96a36452c0..ae624a855d 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx @@ -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; + sampleRow?: Record; } -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(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 ( + + ); + } + + // 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 (
- {/* Source Fields Panel */}
-

- {connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")} -

+

{t("workspace.unify.source_fields")}

{sourceFields.length === 0 ? (
-

- {connectorType === "csv" - ? t("workspace.unify.click_load_sample_csv") - : t("workspace.unify.no_source_fields_loaded")} -

+

{t("workspace.unify.no_source_fields_loaded")}

) : (
@@ -91,42 +120,38 @@ export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorT )}
- {/* Target Fields Panel */}

{t("workspace.unify.feedback_record_fields")}

- - {/* Required Fields */}

{t("workspace.unify.required")}

- {requiredFields.map((field) => ( + {requiredFields.map((targetField) => ( 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)} /> ))}
- {/* Optional Fields */}

{t("workspace.unify.optional")}

- {optionalFields.map((field) => ( + {optionalFields.map((targetField) => ( 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)} /> ))}
@@ -144,3 +169,153 @@ export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorT ); } + +interface CsvMappingFormProps { + sourceFields: TSourceField[]; + mappings: TFieldMapping[]; + onMappingsChange: (mappings: TFieldMapping[]) => void; + confidenceByTargetId?: Record; + sampleRow?: Record; +} + +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 ( + 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 ( +
+
+
+

{t("workspace.unify.csv_basic_required")}

+

{t("workspace.unify.csv_basic_required_hint")}

+
+
{renderGroup(CSV_FIELD_GROUPS.basic)}
+
+ +
+
+

{t("workspace.unify.csv_source_context")}

+

{t("workspace.unify.csv_source_context_hint")}

+
+
{renderGroup(CSV_FIELD_GROUPS.sourceContext)}
+
+ +
+ + {advancedOpen &&
{renderGroup(CSV_FIELD_GROUPS.advanced)}
} +
+
+ ); +}; + +interface ComputePreviewArgs { + responseValueMapping: TFieldMapping | undefined; + fieldTypeMapping: TFieldMapping | undefined; + sampleRow: Record | undefined; + sourceFields: TSourceField[]; + t: ReturnType["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), + }); +}; diff --git a/apps/web/modules/ee/unify-feedback/sources/types.ts b/apps/web/modules/ee/unify-feedback/sources/types.ts index 698393d6b5..c9f15f5f41 100644 --- a/apps/web/modules/ee/unify-feedback/sources/types.ts +++ b/apps/web/modules/ee/unify-feedback/sources/types.ts @@ -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 = { diff --git a/apps/web/modules/ee/unify-feedback/sources/utils.test.ts b/apps/web/modules/ee/unify-feedback/sources/utils.test.ts index 51b03cb055..cb0875fa5d 100644 --- a/apps/web/modules/ee/unify-feedback/sources/utils.test.ts +++ b/apps/web/modules/ee/unify-feedback/sources/utils.test.ts @@ -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"); }); }); diff --git a/apps/web/modules/ee/unify-feedback/sources/utils.ts b/apps/web/modules/ee/unify-feedback/sources/utils.ts index 3832f9923f..162338c0b2 100644 --- a/apps/web/modules/ee/unify-feedback/sources/utils.ts +++ b/apps/web/modules/ee/unify-feedback/sources/utils.ts @@ -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 = { + 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; +} + +interface TAutoMapInput { + sourceFields: TSourceField[]; + sampleRow: Record; + 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 = {}; + const claimedSources = new Set(); + + // 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 }; +}; diff --git a/packages/types/connector.ts b/packages/types/connector.ts index 35585e62a7..2d0a8cb7e1 100644 --- a/packages/types/connector.ts +++ b/packages/types/connector.ts @@ -23,7 +23,9 @@ export const ZHubFieldType = z.enum([ ]); export type THubFieldType = z.infer; -// 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;