fix(vendors): allow sparse reactivation updates

This commit is contained in:
Alexandre Brandizzi 2026-07-28 17:15:42 -03:00
parent 94bd10245b
commit 95cbfff301
8 changed files with 209 additions and 22 deletions

View file

@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) {
async function mockVendorApi( async function mockVendorApi(
page: Page, page: Page,
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, options: {
listError?: boolean;
deactivationBlocked?: boolean;
deleteConflict?: boolean;
legacyGateway?: boolean;
} = {},
) { ) {
const state: MockState = { listUrls: [] }; 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); 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 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 search = (url.searchParams.get("search") ?? "").toLowerCase();
const company = url.searchParams.get("companies[0]"); const company = url.searchParams.get("companies[0]");
const filtered = source.filter( const filtered = source.filter(
@ -175,8 +185,12 @@ async function mockVendorApi(
const id = route.request().url().split("/").pop() ?? ""; const id = route.request().url().split("/").pop() ?? "";
if (route.request().method() === "PUT") { if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON(); 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, { await fulfillJson(route, {
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, data: { ...vendor, ...state.updatedBody, Id: Number(id) },
}); });
return; return;
} }
@ -190,10 +204,12 @@ async function mockVendorApi(
return; return;
} }
state.deletedId = id; state.deletedId = id;
const vendor = vendorRecords.find((item) => String(item.Id) === id);
if (vendor) vendor.IsActive = false;
await fulfillJson(route, { status: "200" }); await fulfillJson(route, { status: "200" });
return; 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 fulfillJson(route, { data: vendor });
}); });
await page.route(/\/api\/vendors\/?$/, async (route) => { await page.route(/\/api\/vendors\/?$/, async (route) => {
@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => {
expect(state.updatedBody?.isActive).toBe(false); 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 }) => { test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
const state = await mockVendorApi(page); const state = await mockVendorApi(page);
await page.goto("/vendors"); await page.goto("/vendors");

View file

@ -1,6 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useForm, useWatch, Controller } from "react-hook-form"; import { useForm, useWatch, Controller, type FieldErrors, type Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch"; import LaunchIcon from "@mui/icons-material/Launch";
@ -20,7 +19,11 @@ import {
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { VendorFormFields } from "./vendor-form-fields"; 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 { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
@ -98,9 +101,32 @@ export function VendorDetailDrawer({
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [original, setOriginal] = useState<VendorFormValues | null>(null); const [original, setOriginal] = useState<VendorFormValues | null>(null);
const originalRef = useRef<VendorFormValues | null>(null);
const companies = useMemo(() => facets?.companies ?? [], [facets]); const companies = useMemo(() => facets?.companies ?? [], [facets]);
const resolver = useCallback<Resolver<VendorFormValues>>(async (values) => {
const result = vendorUpdateSchema.safeParse(
getChangedVendorValues(values, originalRef.current),
);
if (result.success) return { values, errors: {} };
const errors = result.error.issues.reduce<FieldErrors<VendorFormValues>>(
(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 { const {
control, control,
handleSubmit, handleSubmit,
@ -109,7 +135,7 @@ export function VendorDetailDrawer({
trigger, trigger,
formState: { errors }, formState: { errors },
} = useForm<VendorFormValues>({ } = useForm<VendorFormValues>({
resolver: zodResolver(vendorSchema), resolver,
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
companyName: "", companyName: "",
@ -131,17 +157,24 @@ export function VendorDetailDrawer({
}, },
}); });
const currentValues = useWatch({ control }); 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(() => { useEffect(() => {
if (open && detail) { if (open && detail) {
const values = toFormValues(detail); const values = toFormValues(detail);
reset(values); reset(values);
setOriginal(values); setOriginal(values);
originalRef.current = values;
setSubmitError(null); setSubmitError(null);
} }
if (!open) { if (!open) {
setOriginal(null); setOriginal(null);
originalRef.current = null;
setSubmitError(null); setSubmitError(null);
} }
}, [open, detail, reset]); }, [open, detail, reset]);
@ -170,9 +203,11 @@ export function VendorDetailDrawer({
const onSubmit = (values: VendorFormValues) => { const onSubmit = (values: VendorFormValues) => {
if (vendorId === undefined || vendorId === null || vendorId === "") return; if (vendorId === undefined || vendorId === null || vendorId === "") return;
const changed = getChangedVendorValues(values, original);
if (Object.keys(changed).length === 0) return;
setSubmitError(null); setSubmitError(null);
updateVendor.mutate( updateVendor.mutate(
{ id: vendorId, values }, { id: vendorId, values: changed },
{ {
onSuccess: () => { onSuccess: () => {
onModeChange("view"); onModeChange("view");
@ -437,7 +472,7 @@ export function VendorDetailDrawer({
variant="contained" variant="contained"
type="submit" type="submit"
form="vendor-edit-form" form="vendor-edit-form"
disabled={updateVendor.isPending || !isFormValid} disabled={updateVendor.isPending || !hasChanges || !isFormValid}
fullWidth fullWidth
> >
{updateVendor.isPending ? "Saving..." : "Save"} {updateVendor.isPending ? "Saving..." : "Save"}

View file

@ -8,6 +8,7 @@ import {
mapVendorListItems, mapVendorListItems,
mapVendorPortalToken, mapVendorPortalToken,
mapVendorToBackend, mapVendorToBackend,
mapVendorUpdateToBackend,
} from "@/domain/vendors/mappers/vendor-mapper"; } from "@/domain/vendors/mappers/vendor-mapper";
import type { import type {
Vendor, Vendor,
@ -95,7 +96,7 @@ export const vendorsApi = {
update: async (id: string | number, payload: unknown): Promise<Vendor> => { update: async (id: string | number, payload: unknown): Promise<Vendor> => {
const data = await apiPut<unknown>( const data = await apiPut<unknown>(
`${API_PATHS.rest.vendors}/${id}`, `${API_PATHS.rest.vendors}/${id}`,
mapVendorToBackend(payload), mapVendorUpdateToBackend(payload),
); );
return mapVendor(handleApiResponse(data)); return mapVendor(handleApiResponse(data));
}, },

View file

@ -172,6 +172,40 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
return payload; return payload;
} }
export function mapVendorUpdateToBackend(vendor: unknown): Record<string, unknown> {
const item = asRecord(vendor);
const payload: Record<string, unknown> = {};
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 { function mapFacetCompany(raw: unknown): VendorFacetCompany {
const item = asRecord(raw); const item = asRecord(raw);
return { return {

View file

@ -40,3 +40,39 @@ export const vendorSchema = z.object({
}); });
export type VendorFormValues = z.infer<typeof vendorSchema>; export type VendorFormValues = z.infer<typeof vendorSchema>;
export const vendorUpdateSchema = vendorSchema.partial();
export type VendorUpdateValues = z.infer<typeof vendorUpdateSchema>;
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<VendorUpdateValues>((changed, field) => {
if (!Object.is(values[field], original[field])) {
changed[field] = values[field];
}
return changed;
}, {});
}

View file

@ -1,13 +1,13 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api"; 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 type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateVendorInput { interface UpdateVendorInput {
id: string | number; id: string | number;
values: VendorFormValues; values: VendorUpdateValues;
} }
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> { export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {

View file

@ -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({ apiGet.mockResolvedValueOnce({
data: { data: {
id: 7, id: 7,
@ -211,11 +211,26 @@ describe("vendorsApi.getList", () => {
const vendor = await vendorsApi.getById(7); const vendor = await vendorsApi.getById(7);
expect(vendor.phone).toBe("555-1234 ext 9"); expect(vendor.phone).toBe("555-1234 ext 9");
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" }); await vendorsApi.update(7, { notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith( expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, {
`${API_PATHS.rest.vendors}/7`, notes: "Updated only the notes",
expect.objectContaining({ phone: "555-1234 ext 9", 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 () => { it("maps the deactivation-impact payload including linked work orders", async () => {

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; 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 = { const validVendor = {
companyName: "Gateway Plumbing", companyName: "Gateway Plumbing",
@ -59,4 +63,26 @@ describe("vendorSchema", () => {
it("rejects an unknown preferred contact value", () => { it("rejects an unknown preferred contact value", () => {
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false); 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 });
});
}); });