fix(vendors): preserve phone data across edits

This commit is contained in:
Alexandre Brandizzi 2026-07-23 19:17:14 -03:00
parent 922d6c9a57
commit 6ca45bc0ae
6 changed files with 73 additions and 27 deletions

View file

@ -289,7 +289,7 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByLabel("Technician Name").fill("New Technician");
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("(314) 555-0100");
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");

View file

@ -98,7 +98,7 @@ export function VendorFormFields({
label="Technician Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={formatPhoneMask(field.value ?? "")}
value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
@ -208,7 +208,7 @@ export function VendorFormFields({
label="Company Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={formatPhoneMask(field.value ?? "")}
value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}

View file

@ -51,6 +51,14 @@ function readCompanyId(record: Record<string, unknown>): string | number | null
return null;
}
function readNumericCompanyId(record: Record<string, unknown>): number | null {
const value = readCompanyId(record);
if (typeof value === "number") return value;
if (typeof value !== "string" || value.trim() === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function readPreferredContact(
record: Record<string, unknown>,
...keys: string[]
@ -72,10 +80,12 @@ function readPreferredContact(
}
export function toCanonicalPhone(value: unknown): string {
const digits = String(value ?? "")
.replace(/\D+/g, "")
.slice(0, 10);
if (digits.length !== 10) return "";
const original = String(value ?? "");
let digits = original.replace(/\D+/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
digits = digits.slice(1);
}
if (digits.length !== 10) return original;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
@ -95,8 +105,8 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
@ -120,8 +130,8 @@ export function mapVendor(raw: unknown): Vendor {
companyName: readString(item, "companyName", "CompanyName"),
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
phone: readString(item, "phone", "Phone"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
@ -139,7 +149,7 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
const item = asRecord(vendor);
const companyName = readString(item, "companyName", "CompanyName");
const payload: Record<string, unknown> = {
companyId: readCompanyId(item),
companyId: readNumericCompanyId(item),
companyName,
contactName: readString(item, "contactName", "ContactName"),
email: readString(item, "email", "Email"),
@ -167,7 +177,7 @@ function mapFacetCompany(raw: unknown): VendorFacetCompany {
return {
companyId: readCompanyId(item),
name: readString(item, "name", "Name"),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
email: readString(item, "email", "Email"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),

View file

@ -12,19 +12,18 @@ const httpsUrl = z
"Google Maps URL must use HTTPS",
);
const canonicalPhone = z
.string()
.refine(
(value) => value === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(value),
"Enter a 10-digit phone number",
);
const northAmericanPhone = z.string().refine((value) => {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
}, "Enter a 10-digit phone number");
export const vendorSchema = z.object({
companyName: z.string().min(1, "Company is required"),
contactName: z.string().min(1, "Technician name is required"),
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
phone: canonicalPhone.optional(),
companyPhone: canonicalPhone.optional(),
phone: northAmericanPhone.optional(),
companyPhone: northAmericanPhone.optional(),
googleMapsUrl: httpsUrl.optional(),
notes: z.string().optional(),
totalJobs: z.number().optional(),

View file

@ -101,7 +101,7 @@ describe("vendorsApi.getList", () => {
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
expect(facets.companies[0]).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
companyPhone: "314-555-0100",
googleMapsUrl: "https://maps.google.com/gateway",
});
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
@ -126,10 +126,10 @@ describe("vendorsApi.getList", () => {
it("sends all editable prototype fields when creating and updating", async () => {
const values = {
companyId: "co-1",
companyId: "42",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
phone: "314-555-0198",
phone: "+1 314-555-0198",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
@ -154,7 +154,7 @@ describe("vendorsApi.getList", () => {
expect.objectContaining({
companyName: "Gateway Plumbing",
notes: values.notes,
companyId: "co-1",
companyId: 42,
preferredContact: "Text",
phone: "(314) 555-0198",
companyPhone: "(314) 555-0100",
@ -182,6 +182,42 @@ describe("vendorsApi.getList", () => {
);
});
it("does not send a non-numeric company id to the integer backend contract", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyId: null }),
);
});
it("preserves a legacy phone on read and unrelated write", async () => {
apiGet.mockResolvedValueOnce({
data: {
id: 7,
companyName: "Legacy Vendor",
contactName: "Taylor",
phone: "555-1234 ext 9",
},
});
apiPut.mockResolvedValueOnce({ data: {} });
const vendor = await vendorsApi.getById(7);
expect(vendor.phone).toBe("555-1234 ext 9");
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
);
});
it("maps the deactivation-impact payload including linked work orders", async () => {
apiGet.mockResolvedValueOnce({
data: {

View file

@ -35,14 +35,15 @@ describe("vendorSchema", () => {
).toBe(false);
});
it("accepts empty phones and canonical (XXX) XXX-XXXX phones, rejecting partials", () => {
it("accepts canonicalizable North American phones and rejects partials", () => {
expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
true,
);
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(false);
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
});