diff --git a/web/src/components/app/contacts/importShared.test.ts b/web/src/components/app/contacts/importShared.test.ts new file mode 100644 index 00000000..3abb510a --- /dev/null +++ b/web/src/components/app/contacts/importShared.test.ts @@ -0,0 +1,70 @@ +// Issue #207: a 1,000-row import failed every row with "invalid custom field +// key: Company Mobile". The client now enforces the same rule the API does, so +// a name it cannot use is caught on the mapping screen. These cases mirror +// internal/utils/json.go — if that rule moves, this is where it shows up. + +import { describe, it, expect } from "vitest"; +import { + isValidCustomKey, + mappingProblem, + normalizeCustomKey, + suggestCustomKey, +} from "./importShared"; +import type { ImportColumnMapping } from "@/lib/api/client/app/contacts/importContacts"; + +describe("custom field names", () => { + it("accepts what the template engine can resolve", () => { + for (const key of ["role", "Job_Title", "Company Mobile", "first-name", "plan tier 2"]) { + expect(isValidCustomKey(key)).toBe(true); + } + }); + + it("rejects names nothing could merge into an email", () => { + for (const key of ["", " ", "Company/Mobile", "Revenue ($)", "a.b", "тест"]) { + expect(isValidCustomKey(key)).toBe(false); + } + }); + + it("collapses stray whitespace so one column is one field", () => { + expect(normalizeCustomKey(" Company Mobile ")).toBe("Company Mobile"); + expect(isValidCustomKey(" Company Mobile ")).toBe(true); + }); + + it("turns a spreadsheet header into a usable name", () => { + expect(suggestCustomKey("Company Mobile")).toBe("Company Mobile"); + expect(suggestCustomKey("Revenue ($)")).toBe("Revenue"); + expect(suggestCustomKey("Annual Revenue (USD)")).toBe("Annual Revenue USD"); + expect(suggestCustomKey("###")).toBe(""); + }); +}); + +describe("mappingProblem", () => { + const email: ImportColumnMapping = { index: 0, target: "email" }; + + it("passes a mapping the API would accept", () => { + expect( + mappingProblem([email, { index: 5, target: "custom", custom_key: "Company Mobile" }]), + ).toBeNull(); + }); + + it("names the column when a custom field has no name", () => { + expect(mappingProblem([email, { index: 5, target: "custom", custom_key: " " }])).toBe( + "Column 6 needs a custom field name.", + ); + }); + + it("explains an unusable name instead of letting the import fail row by row", () => { + const msg = mappingProblem([email, { index: 5, target: "custom", custom_key: "Company/Mobile" }]); + expect(msg).toContain("Company/Mobile"); + expect(msg).toContain("letters"); + }); + + it("still requires an email column", () => { + expect(mappingProblem([{ index: 1, target: "first_name" }])).toBe("Map a column to Email."); + }); + + it("understands the legacy custom: spelling", () => { + expect(mappingProblem([email, { index: 2, target: "custom:plan_tier" }])).toBeNull(); + expect(mappingProblem([email, { index: 2, target: "custom:plan/tier" }])).toContain("plan/tier"); + }); +}); diff --git a/web/src/components/app/contacts/importShared.ts b/web/src/components/app/contacts/importShared.ts index c48a8341..6b6dc43c 100644 --- a/web/src/components/app/contacts/importShared.ts +++ b/web/src/components/app/contacts/importShared.ts @@ -6,6 +6,7 @@ import toast from "react-hot-toast"; import type { + ImportColumnMapping, ImportDedupStrategy, ImportResult, } from "@/lib/api/client/app/contacts/importContacts"; @@ -61,3 +62,57 @@ export function announceResult(res: ImportResult) { toast(`Synced with ${res.failed} errors`, { icon: "⚠️" }); } } + +// ----- Custom-field names ----------------------------------------- +// +// Mirrors internal/utils.IsValidJSONKey. A custom field is addressable in +// campaign copy either as {{.Role}} or, for a spaced/dashed name, through the +// server-side rewrite to (index . "Company Mobile"). Anything else would make +// a field the user can store but never merge into an email, so the API rejects +// it — we catch it here so a mistyped name never costs a whole import. +const CUSTOM_KEY_RE = /^[A-Za-z0-9_]+(?:[ -]+[A-Za-z0-9_]+)*$/; + +export const CUSTOM_KEY_RULES = "Use letters, numbers, underscores, spaces or dashes."; + +export function normalizeCustomKey(key: string): string { + return key.trim().split(/\s+/).filter(Boolean).join(" "); +} + +export function isValidCustomKey(key: string): boolean { + const k = normalizeCustomKey(key); + return k.length > 0 && k.length <= 255 && CUSTOM_KEY_RE.test(k); +} + +// suggestCustomKey turns a raw spreadsheet header into a name the API accepts, +// so picking "Use as custom field" on a "Company Mobile" column just works. +// Returns "" when nothing usable survives and the user has to type a name. +export function suggestCustomKey(header: string): string { + const cleaned = normalizeCustomKey(header.replace(/[^A-Za-z0-9_ -]+/g, " ")) + .replace(/^[-\s]+/, "") + .replace(/[-\s]+$/, ""); + return isValidCustomKey(cleaned) ? cleaned : ""; +} + +export function isCustomTarget(target: string): boolean { + return target === "custom" || target.startsWith("custom:"); +} + +// mappingProblem returns the first reason the mapping cannot be committed, or +// null when it is good to go. Same order of checks as the server so the two +// never disagree about which column is at fault. +export function mappingProblem(mapping: ImportColumnMapping[]): string | null { + for (const m of mapping) { + if (!isCustomTarget(m.target)) continue; + const key = m.custom_key ?? (m.target.startsWith("custom:") ? m.target.slice(7) : ""); + if (normalizeCustomKey(key) === "") { + return `Column ${m.index + 1} needs a custom field name.`; + } + if (!isValidCustomKey(key)) { + return `"${key.trim()}" is not a valid field name. ${CUSTOM_KEY_RULES}`; + } + } + if (!mapping.some((m) => m.target === "email")) { + return "Map a column to Email."; + } + return null; +}