feat: mirror the server's custom-field name rule in web importShared with normalizeCustomKey, isValidCustomKey, suggestCustomKey (which turns a spreadsheet header such as "Revenue ($)" into a name the API accepts) and mappingProblem, which returns the first reason a column mapping cannot be committed in the same order the server checks it, plus unit tests pinning the two rules together

This commit is contained in:
Matthew Meszaros
2026-08-27 03:45:50 -07:00
parent 995ca1697b
commit 3fafe276a9
2 changed files with 125 additions and 0 deletions
@@ -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:<key> spelling", () => {
expect(mappingProblem([email, { index: 2, target: "custom:plan_tier" }])).toBeNull();
expect(mappingProblem([email, { index: 2, target: "custom:plan/tier" }])).toContain("plan/tier");
});
});
@@ -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;
}