mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
cleanup code and enhance test coverage
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user