diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 29df8921..7fc5f9cf 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) { async function mockVendorApi( page: Page, - options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, + options: { + listError?: boolean; + deactivationBlocked?: boolean; + deleteConflict?: boolean; + legacyGateway?: boolean; + } = {}, ) { const state: MockState = { listUrls: [] }; + const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); + if (options.legacyGateway) { + vendorRecords[0].Phone = "555-1234 ext 9"; + vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; + } await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); @@ -131,7 +141,7 @@ async function mockVendorApi( } const isActive = url.searchParams.get("isActive") !== "false"; - const source = isActive ? activeVendors : inactiveVendors; + const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive); const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); const filtered = source.filter( @@ -175,8 +185,12 @@ async function mockVendorApi( const id = route.request().url().split("/").pop() ?? ""; if (route.request().method() === "PUT") { state.updatedBody = route.request().postDataJSON(); + const vendor = vendorRecords.find((item) => String(item.Id) === id); + if (vendor && typeof state.updatedBody?.isActive === "boolean") { + vendor.IsActive = state.updatedBody.isActive; + } await fulfillJson(route, { - data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, + data: { ...vendor, ...state.updatedBody, Id: Number(id) }, }); return; } @@ -190,10 +204,12 @@ async function mockVendorApi( return; } state.deletedId = id; + const vendor = vendorRecords.find((item) => String(item.Id) === id); + if (vendor) vendor.IsActive = false; await fulfillJson(route, { status: "200" }); return; } - const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id); + const vendor = vendorRecords.find((item) => String(item.Id) === id); await fulfillJson(route, { data: vendor }); }); await page.route(/\/api\/vendors\/?$/, async (route) => { @@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => { expect(state.updatedBody?.isActive).toBe(false); }); + test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { + const state = await mockVendorApi(page, { legacyGateway: true }); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Deactivate", exact: true }).click(); + await expect.poll(() => state.deletedId).toBe("1"); + + await page.getByRole("tab", { name: "Inactive (2)" }).click(); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled(); + + await detailDrawer.getByRole("switch", { name: "Inactive" }).click(); + await detailDrawer.getByRole("button", { name: "Save" }).click(); + await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await expect(detailDrawer.getByText("Active").first()).toBeVisible(); + + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); + await page.getByRole("tab", { name: "Active (2)" }).click(); + await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); + }); + test("supports keyboard dismissal and free-text company creation", async ({ page }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 9dcd5eed..68f09a35 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { useForm, useWatch, Controller } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useForm, useWatch, Controller, type FieldErrors, type Resolver } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -20,7 +19,11 @@ import { Typography, } from "@mui/material"; import { VendorFormFields } from "./vendor-form-fields"; -import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { + getChangedVendorValues, + vendorUpdateSchema, + type VendorFormValues, +} from "@/domain/vendors/schemas/vendor-schema"; import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; @@ -98,9 +101,32 @@ export function VendorDetailDrawer({ const [submitError, setSubmitError] = useState(null); const [original, setOriginal] = useState(null); + const originalRef = useRef(null); const companies = useMemo(() => facets?.companies ?? [], [facets]); + const resolver = useCallback>(async (values) => { + const result = vendorUpdateSchema.safeParse( + getChangedVendorValues(values, originalRef.current), + ); + if (result.success) return { values, errors: {} }; + + const errors = result.error.issues.reduce>( + (fieldErrors, issue) => { + const field = issue.path[0]; + if (typeof field === "string") { + fieldErrors[field as keyof VendorFormValues] = { + type: issue.code, + message: issue.message, + }; + } + return fieldErrors; + }, + {}, + ); + return { values: {}, errors }; + }, []); + const { control, handleSubmit, @@ -109,7 +135,7 @@ export function VendorDetailDrawer({ trigger, formState: { errors }, } = useForm({ - resolver: zodResolver(vendorSchema), + resolver, mode: "onChange", defaultValues: { companyName: "", @@ -131,17 +157,24 @@ export function VendorDetailDrawer({ }, }); const currentValues = useWatch({ control }); - const isFormValid = vendorSchema.safeParse(currentValues).success; + const changedValues = useMemo( + () => getChangedVendorValues(currentValues, original), + [currentValues, original], + ); + const hasChanges = Object.keys(changedValues).length > 0; + const isFormValid = vendorUpdateSchema.safeParse(changedValues).success; useEffect(() => { if (open && detail) { const values = toFormValues(detail); reset(values); setOriginal(values); + originalRef.current = values; setSubmitError(null); } if (!open) { setOriginal(null); + originalRef.current = null; setSubmitError(null); } }, [open, detail, reset]); @@ -170,9 +203,11 @@ export function VendorDetailDrawer({ const onSubmit = (values: VendorFormValues) => { if (vendorId === undefined || vendorId === null || vendorId === "") return; + const changed = getChangedVendorValues(values, original); + if (Object.keys(changed).length === 0) return; setSubmitError(null); updateVendor.mutate( - { id: vendorId, values }, + { id: vendorId, values: changed }, { onSuccess: () => { onModeChange("view"); @@ -437,7 +472,7 @@ export function VendorDetailDrawer({ variant="contained" type="submit" form="vendor-edit-form" - disabled={updateVendor.isPending || !isFormValid} + disabled={updateVendor.isPending || !hasChanges || !isFormValid} fullWidth > {updateVendor.isPending ? "Saving..." : "Save"} diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 4f001f04..467a3a3b 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -8,6 +8,7 @@ import { mapVendorListItems, mapVendorPortalToken, mapVendorToBackend, + mapVendorUpdateToBackend, } from "@/domain/vendors/mappers/vendor-mapper"; import type { Vendor, @@ -95,7 +96,7 @@ export const vendorsApi = { update: async (id: string | number, payload: unknown): Promise => { const data = await apiPut( `${API_PATHS.rest.vendors}/${id}`, - mapVendorToBackend(payload), + mapVendorUpdateToBackend(payload), ); return mapVendor(handleApiResponse(data)); }, diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 9273ef8b..363d9595 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -172,6 +172,40 @@ export function mapVendorToBackend(vendor: unknown): Record { return payload; } +export function mapVendorUpdateToBackend(vendor: unknown): Record { + const item = asRecord(vendor); + const payload: Record = {}; + const fieldMappings = [ + ["companyName", "CompanyName"], + ["contactName", "ContactName"], + ["email", "Email"], + ["googleMapsUrl", "GoogleMapsUrl"], + ["notes", "Notes"], + ["address", "Address"], + ["city", "City"], + ["state", "State"], + ["zip", "Zip"], + ["tradeSpecialties", "TradeSpecialties"], + ["isActive", "IsActive"], + ["preferredContact", "PreferredContact"], + ] as const; + + fieldMappings.forEach(([target, source]) => { + if (Object.hasOwn(item, target)) payload[target] = item[target]; + if (Object.hasOwn(item, source)) payload[target] = item[source]; + }); + if (Object.hasOwn(item, "companyId") || Object.hasOwn(item, "CompanyId")) { + payload.companyId = readNumericCompanyId(item); + } + if (Object.hasOwn(item, "phone") || Object.hasOwn(item, "Phone")) { + payload.phone = toCanonicalPhone(item.phone ?? item.Phone); + } + if (Object.hasOwn(item, "companyPhone") || Object.hasOwn(item, "CompanyPhone")) { + payload.companyPhone = toCanonicalPhone(item.companyPhone ?? item.CompanyPhone); + } + return payload; +} + function mapFacetCompany(raw: unknown): VendorFacetCompany { const item = asRecord(raw); return { diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 7ce3be5e..72216e06 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -40,3 +40,39 @@ export const vendorSchema = z.object({ }); export type VendorFormValues = z.infer; + +export const vendorUpdateSchema = vendorSchema.partial(); + +export type VendorUpdateValues = z.infer; + +const VENDOR_UPDATE_FIELDS = [ + "companyName", + "contactName", + "email", + "phone", + "companyPhone", + "googleMapsUrl", + "notes", + "address", + "city", + "state", + "zip", + "tradeSpecialties", + "isActive", + "companyId", + "preferredContact", +] as const satisfies readonly (keyof VendorFormValues)[]; + +export function getChangedVendorValues( + values: VendorFormValues, + original: VendorFormValues | null, +): VendorUpdateValues { + if (!original) return {}; + + return VENDOR_UPDATE_FIELDS.reduce((changed, field) => { + if (!Object.is(values[field], original[field])) { + changed[field] = values[field]; + } + return changed; + }, {}); +} diff --git a/src/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts index cf4f4c84..836b0e45 100644 --- a/src/domain/vendors/use-cases/use-update-vendor.ts +++ b/src/domain/vendors/use-cases/use-update-vendor.ts @@ -1,13 +1,13 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { vendorsApi } from "@/domain/vendors/api/vendors-api"; -import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import type { VendorUpdateValues } from "@/domain/vendors/schemas/vendor-schema"; import type { Vendor } from "@/domain/vendors/types/vendor"; import { queryKeys } from "@/infra/query-key/query-key"; interface UpdateVendorInput { id: string | number; - values: VendorFormValues; + values: VendorUpdateValues; } export function useUpdateVendor(): UseMutationResult { diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index b66ce1bf..e6ff71bc 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -197,7 +197,7 @@ describe("vendorsApi.getList", () => { ); }); - it("preserves a legacy phone on read and unrelated write", async () => { + it("preserves a legacy phone on read and omits it from an unrelated update", async () => { apiGet.mockResolvedValueOnce({ data: { id: 7, @@ -211,11 +211,26 @@ describe("vendorsApi.getList", () => { 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" }), - ); + await vendorsApi.update(7, { notes: "Updated only the notes" }); + expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, { + notes: "Updated only the notes", + }); + }); + + it("sends only changed fields and canonicalizes a supplied phone", async () => { + apiPut.mockResolvedValueOnce({ data: {} }); + + await vendorsApi.update(7, { + isActive: false, + companyId: null, + phone: "314-555-0198", + }); + + expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, { + isActive: false, + companyId: null, + phone: "(314) 555-0198", + }); }); it("maps the deactivation-impact payload including linked work orders", async () => { diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index 244c2b8e..726ae030 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema"; +import { + getChangedVendorValues, + vendorSchema, + vendorUpdateSchema, +} from "@/domain/vendors/schemas/vendor-schema"; const validVendor = { companyName: "Gateway Plumbing", @@ -59,4 +63,26 @@ describe("vendorSchema", () => { it("rejects an unknown preferred contact value", () => { expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false); }); + + it("validates only edited fields while retaining required and format checks", () => { + expect(vendorUpdateSchema.safeParse({ isActive: true }).success).toBe(true); + expect(vendorUpdateSchema.safeParse({ companyName: "" }).success).toBe(false); + expect(vendorUpdateSchema.safeParse({ phone: "555-1234 ext 9" }).success).toBe(false); + }); + + it("returns a sparse update that preserves false and null and omits totalJobs", () => { + const original = { + ...validVendor, + totalJobs: 12, + isActive: false, + companyId: "co-1", + preferredContact: "Phone" as const, + }; + const changed = getChangedVendorValues( + { ...original, isActive: true, companyId: null, totalJobs: 13 }, + original, + ); + + expect(changed).toEqual({ isActive: true, companyId: null }); + }); });