mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user