cleanup code and enhance test coverage

This commit is contained in:
Javi Aguilar
2026-05-07 12:50:50 +02:00
parent 470a5fe6e1
commit 2cb4e27551
7 changed files with 74 additions and 81 deletions
@@ -165,15 +165,15 @@ export const getAccessControlPermission = async (organizationId: string): Promis
};
export const getIsUnifyFeedbackEnabled = async (organizationId: string): Promise<boolean> => {
return true; //getCustomPlanFeaturePermission(organizationId, "unifyFeedback");
return getCustomPlanFeaturePermission(organizationId, "unifyFeedback");
};
export const getIsFeedbackDirectoriesEnabled = async (organizationId: string): Promise<boolean> => {
return true; // getCustomPlanFeaturePermission(organizationId, "feedbackDirectories");
return getCustomPlanFeaturePermission(organizationId, "feedbackDirectories");
};
export const getIsDashboardsEnabled = async (organizationId: string): Promise<boolean> => {
return true; //getCustomPlanFeaturePermission(organizationId, "dashboards");
return getCustomPlanFeaturePermission(organizationId, "dashboards");
};
export const getOrganizationWorkspacesLimit = async (organizationId: string): Promise<number> => {
@@ -44,8 +44,6 @@ export function CsvConnectorUI({
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);
@@ -68,10 +66,6 @@ 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]));
@@ -106,7 +100,6 @@ export function CsvConnectorUI({
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) {
@@ -206,7 +199,6 @@ export function CsvConnectorUI({
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");
@@ -359,7 +359,6 @@ export const DroppableTargetField = ({
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return t("workspace.unify.feedback_date");
return value;
@@ -422,17 +421,13 @@ const SENTINEL = {
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;
@@ -459,9 +454,6 @@ export const FormTargetField = ({
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) {
@@ -476,8 +468,6 @@ export const FormTargetField = ({
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;
}
@@ -97,7 +97,6 @@ export function MappingUI({
);
}
// 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);
@@ -175,9 +175,6 @@ 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",
@@ -187,8 +184,6 @@ export const CSV_RESPONSE_VALUE_TARGET: TTargetField = {
"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",
@@ -215,14 +210,10 @@ export const CSV_FIELD_GROUPS = {
],
} 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" },
{ sourceFieldId: "", 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";
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { ZHubFieldType } from "@formbricks/types/connector";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import { CSV_HIDDEN_STATIC_MAPPINGS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
areAllRequiredCsvFieldsMapped,
autoMapCsvSourceFields,
@@ -181,11 +181,27 @@ describe("areAllRequiredCsvFieldsMapped", () => {
expect(areAllRequiredCsvFieldsMapped(incomplete).missing).toContain("field_type");
});
test("treats invalid static field_type as unmapped", () => {
const invalidFieldType: TFieldMapping[] = [
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
{ targetFieldId: "field_type", staticValue: "not_a_field_type" },
];
expect(areAllRequiredCsvFieldsMapped(invalidFieldType).missing).toContain("field_type");
});
test("does not require collected_at (defaults to $now)", () => {
expect(areAllRequiredCsvFieldsMapped(fullMappings).missing).not.toContain("collected_at");
});
});
describe("CSV_HIDDEN_STATIC_MAPPINGS", () => {
test("persists source_type=csv with a valid static mapping shape", () => {
expect(CSV_HIDDEN_STATIC_MAPPINGS).toEqual([
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
]);
});
});
describe("titleizeFromFileName", () => {
test.each([
["feedback.csv", "Feedback"],
@@ -267,7 +283,6 @@ describe("routeResponseValueTarget", () => {
test("covers every THubFieldType enum value", () => {
for (const fieldType of ZHubFieldType.options) {
// Throws if any enum member is unhandled.
expect(() => routeResponseValueTarget(fieldType)).not.toThrow();
}
});
@@ -321,9 +336,17 @@ describe("autoMapCsvSourceFields", () => {
expect(result.confidence.source_name).toBe("high");
});
test("keeps source_name filename-derived even when the CSV has a source_name column", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["source_name", "question", "answer"]),
sampleRow: { source_name: "malicious", question: "q1", answer: "yes" },
fileName: "trusted-file.csv",
});
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
expect(mapping).toEqual({ targetFieldId: "source_name", staticValue: "Trusted File" });
});
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" },
@@ -334,18 +357,34 @@ describe("autoMapCsvSourceFields", () => {
expect(result.confidence.field_id).toBe("high");
});
test("maps realistic QA headers without leaving required basics unresolved", () => {
const result = autoMapCsvSourceFields({
sourceFields: buildSourceFields(["timestamp", "email", "question", "answer", "language", "score"]),
sampleRow: {
timestamp: "2026-01-01",
email: "person@example.com",
question: "How satisfied are you?",
answer: "Great",
language: "en",
score: "9",
},
fileName: "google-forms-export.csv",
});
const validation = areAllRequiredCsvFieldsMapped(result.mappings);
expect(validation).toEqual({ valid: true, missing: [] });
expect(result.mappings.find((m) => m.targetFieldId === "field_id")?.sourceFieldId).toBe("question");
expect(result.mappings.find((m) => m.targetFieldId === "field_label")?.sourceFieldId).toBe("question");
expect(result.confidence.field_id).toBe("low");
});
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");
});
@@ -1,5 +1,5 @@
import { TFunction } from "i18next";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import {
CSV_REQUIRED_UI_FIELDS,
CSV_TARGET_FIELDS,
@@ -59,10 +59,6 @@ export interface TEnumValidationError {
allowedValues: string[];
}
/**
* Validates that CSV columns mapped to enum target fields contain only allowed values.
* Returns an array of validation errors (empty if all valid).
*/
export const validateEnumMappings = (
mappings: TFieldMapping[],
csvData: Record<string, string>[]
@@ -123,7 +119,6 @@ export const validateCsvFile = (
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);
@@ -131,8 +126,6 @@ export const titleizeFromFileName = (fileName: string): string => {
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],
@@ -146,8 +139,6 @@ export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp
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],
@@ -156,10 +147,6 @@ export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp
high: [/^(source_id|survey_id|form_id)$/i],
medium: [],
},
source_name: {
high: [/^source_name$/i],
medium: [],
},
language: {
high: [/^(language|lang|locale)$/i],
medium: [],
@@ -174,8 +161,6 @@ export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp
},
};
// 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" },
@@ -188,8 +173,6 @@ export const FIELD_TYPE_NAME_HINTS: Array<{ pattern: RegExp; type: THubFieldType
{ 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,
@@ -218,8 +201,6 @@ export const inferFieldType = ({
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" => {
@@ -270,7 +251,6 @@ const findBestSourceMatch = (
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" };
@@ -278,10 +258,6 @@ const findBestSourceMatch = (
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,
@@ -291,10 +267,8 @@ export const autoMapCsvSourceFields = ({
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;
@@ -309,7 +283,6 @@ export const autoMapCsvSourceFields = ({
}
}
// Second pass: medium-confidence matches for still-unmapped targets.
for (const targetId of orderedTargets) {
if (confidence[targetId]) continue;
const aliases = CSV_COLUMN_ALIASES[targetId];
@@ -325,7 +298,6 @@ export const autoMapCsvSourceFields = ({
}
}
// 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));
@@ -337,20 +309,22 @@ export const autoMapCsvSourceFields = ({
}
}
// 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";
mappings.push({ targetFieldId: "source_name", staticValue: titleizeFromFileName(fileName) });
confidence.source_name = "high";
if (!confidence.field_id) {
const labelMapping = mappings.find((m) => m.targetFieldId === "field_label" && m.sourceFieldId);
if (labelMapping?.sourceFieldId) {
mappings.push({ targetFieldId: "field_id", sourceFieldId: labelMapping.sourceFieldId });
confidence.field_id = "low";
}
}
// 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) {
@@ -360,7 +334,6 @@ export const autoMapCsvSourceFields = ({
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;
@@ -371,8 +344,6 @@ export const autoMapCsvSourceFields = ({
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[] } => {
@@ -380,7 +351,18 @@ export const areAllRequiredCsvFieldsMapped = (
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);
if (!resolved) {
missing.push(requiredId);
continue;
}
if (
requiredId === "field_type" &&
mapping?.staticValue &&
!ZHubFieldType.safeParse(mapping.staticValue).success
) {
missing.push(requiredId);
}
}
return { valid: missing.length === 0, missing };
};