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(
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");

View file

@ -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<string | null>(null);
const [original, setOriginal] = useState<VendorFormValues | null>(null);
const originalRef = useRef<VendorFormValues | null>(null);
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 {
control,
handleSubmit,
@ -109,7 +135,7 @@ export function VendorDetailDrawer({
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
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"}

View file

@ -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<Vendor> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.vendors}/${id}`,
mapVendorToBackend(payload),
mapVendorUpdateToBackend(payload),
);
return mapVendor(handleApiResponse(data));
},

View file

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

View file

@ -40,3 +40,39 @@ export const vendorSchema = z.object({
});
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 { 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<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({
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 () => {

View file

@ -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 });
});
});