mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 19:03:12 +00:00
fix(vendors): allow sparse reactivation updates
This commit is contained in:
parent
94bd10245b
commit
95cbfff301
8 changed files with 209 additions and 22 deletions
48
e2e/vendors/vendors.spec.ts
vendored
48
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
3
src/domain/vendors/api/vendors-api.ts
vendored
3
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -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));
|
||||
},
|
||||
|
|
|
|||
34
src/domain/vendors/mappers/vendor-mapper.ts
vendored
34
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
36
src/domain/vendors/schemas/vendor-schema.ts
vendored
36
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
27
src/test/domain/vendors/api/vendors-api.test.ts
vendored
27
src/test/domain/vendors/api/vendors-api.test.ts
vendored
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue