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.getByLabel("Technician Name").fill("New Technician");
await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).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.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); 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" label="Technician Phone"
placeholder="(XXX) XXX-XXXX" placeholder="(XXX) XXX-XXXX"
inputMode="tel" inputMode="tel"
value={formatPhoneMask(field.value ?? "")} value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.phone)} error={Boolean(errors.phone)}
helperText={errors.phone?.message} helperText={errors.phone?.message}
@ -208,7 +208,7 @@ export function VendorFormFields({
label="Company Phone" label="Company Phone"
placeholder="(XXX) XXX-XXXX" placeholder="(XXX) XXX-XXXX"
inputMode="tel" inputMode="tel"
value={formatPhoneMask(field.value ?? "")} value={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.companyPhone)} error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message} helperText={errors.companyPhone?.message}

View file

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

View file

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

View file

@ -101,7 +101,7 @@ describe("vendorsApi.getList", () => {
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
expect(facets.companies[0]).toMatchObject({ expect(facets.companies[0]).toMatchObject({
name: "Gateway Plumbing", name: "Gateway Plumbing",
companyPhone: "(314) 555-0100", companyPhone: "314-555-0100",
googleMapsUrl: "https://maps.google.com/gateway", googleMapsUrl: "https://maps.google.com/gateway",
}); });
expect(facets.locations[0]?.label).toBe("St. Louis, MO"); 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 () => { it("sends all editable prototype fields when creating and updating", async () => {
const values = { const values = {
companyId: "co-1", companyId: "42",
companyName: "Gateway Plumbing", companyName: "Gateway Plumbing",
contactName: "Adam Whyte", contactName: "Adam Whyte",
phone: "314-555-0198", phone: "+1 314-555-0198",
companyPhone: "314-555-0100", companyPhone: "314-555-0100",
email: "dispatch@gateway.test", email: "dispatch@gateway.test",
address: "1 Market St", address: "1 Market St",
@ -154,7 +154,7 @@ describe("vendorsApi.getList", () => {
expect.objectContaining({ expect.objectContaining({
companyName: "Gateway Plumbing", companyName: "Gateway Plumbing",
notes: values.notes, notes: values.notes,
companyId: "co-1", companyId: 42,
preferredContact: "Text", preferredContact: "Text",
phone: "(314) 555-0198", phone: "(314) 555-0198",
companyPhone: "(314) 555-0100", 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 () => { it("maps the deactivation-impact payload including linked work orders", async () => {
apiGet.mockResolvedValueOnce({ apiGet.mockResolvedValueOnce({
data: { data: {

View file

@ -35,14 +35,15 @@ describe("vendorSchema", () => {
).toBe(false); ).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( expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
true, true,
); );
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe( expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
true, 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); expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
}); });