mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 21:12:07 +00:00
fix(vendors): preserve phone data across edits
This commit is contained in:
parent
922d6c9a57
commit
6ca45bc0ae
6 changed files with 73 additions and 27 deletions
2
e2e/vendors/vendors.spec.ts
vendored
2
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
30
src/domain/vendors/mappers/vendor-mapper.ts
vendored
30
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -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"),
|
||||||
|
|
|
||||||
15
src/domain/vendors/schemas/vendor-schema.ts
vendored
15
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -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(),
|
||||||
|
|
|
||||||
44
src/test/domain/vendors/api/vendors-api.test.ts
vendored
44
src/test/domain/vendors/api/vendors-api.test.ts
vendored
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue