mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 01:33:12 +00:00
fix(vendors): additive technician add for existing companies (SH-250, SH-246)
This commit is contained in:
parent
b4cb9d0288
commit
a710e21281
8 changed files with 756 additions and 22 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||
import {
|
||||
emptyVendorCompanyRosterForm,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||
import {
|
||||
buildAdditiveRosterPatch,
|
||||
getSingleStatusOnlyChange,
|
||||
useSaveVendorCompanyRoster,
|
||||
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
|
|
@ -129,17 +131,38 @@ export function useVendorRosterForm({
|
|||
clearConflict();
|
||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||
|
||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||
|
||||
const submit = (formValues: VendorCompanyRosterFormValues) => {
|
||||
setConflict(null);
|
||||
const values = withoutBlankNewTechnicians(formValues);
|
||||
if (mode === "create" && selection.selectedRoster != null) {
|
||||
const selected = selection.selectedRoster;
|
||||
if (!buildAdditiveRosterPatch(selected, values, selected.rowVersion)) {
|
||||
toast.error("Enter at least one technician to add to this vendor.");
|
||||
return;
|
||||
}
|
||||
save.mutate(
|
||||
{
|
||||
mode: "add",
|
||||
values,
|
||||
companyId: selected.companyId,
|
||||
rowVersion: selected.rowVersion,
|
||||
originalRoster: selected,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => onSuccess?.(data),
|
||||
onError: (error) => {
|
||||
if (isVendorRosterConflictError(error)) setConflict(error.conflict);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
save.mutate(
|
||||
{
|
||||
mode: isUpdate ? "update" : "create",
|
||||
values: withoutBlankNewTechnicians(formValues),
|
||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||
mode: mode === "update" ? "update" : "create",
|
||||
values,
|
||||
companyId: mode === "update" ? (routeRoster?.companyId ?? companyId) : undefined,
|
||||
rowVersion: mode === "update" ? routeRoster?.rowVersion : undefined,
|
||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
emptyRosterTechnician,
|
||||
emptyVendorCompanyRosterForm,
|
||||
type VendorCompanyRosterFormValues,
|
||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
|
|
@ -53,6 +54,20 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
|
|||
};
|
||||
}
|
||||
|
||||
export function toCompanyFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues {
|
||||
return { ...toFormValues(roster), technicians: [] };
|
||||
}
|
||||
|
||||
function withCompanyFieldsOnly(
|
||||
roster: VendorCompanyRoster,
|
||||
): (current: VendorCompanyRosterFormValues) => VendorCompanyRosterFormValues {
|
||||
return (current) => ({
|
||||
...toCompanyFormValues(roster),
|
||||
technicians:
|
||||
current.technicians.length > 0 ? current.technicians : [{ ...emptyRosterTechnician }],
|
||||
});
|
||||
}
|
||||
|
||||
export function useVendorRosterSelection({
|
||||
mode,
|
||||
reset,
|
||||
|
|
@ -91,7 +106,7 @@ export function useVendorRosterSelection({
|
|||
try {
|
||||
const roster = await fetchRosterByCompany(company.companyId);
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
reset(toFormValues(roster));
|
||||
reset(withCompanyFieldsOnly(roster));
|
||||
setSelectedRoster(roster);
|
||||
clearConflict();
|
||||
retryTargetRef.current = null;
|
||||
|
|
@ -107,7 +122,11 @@ export function useVendorRosterSelection({
|
|||
(nextName = "") => {
|
||||
requestIdRef.current += 1;
|
||||
if (selectedRoster) {
|
||||
reset({ ...emptyVendorCompanyRosterForm, name: nextName });
|
||||
reset((current) => ({
|
||||
...emptyVendorCompanyRosterForm,
|
||||
name: nextName,
|
||||
technicians: current.technicians,
|
||||
}));
|
||||
}
|
||||
setSelectedRoster(null);
|
||||
clearConflict();
|
||||
|
|
@ -134,7 +153,6 @@ export function useVendorRosterSelection({
|
|||
void fetchRosterByCompany(selectedRoster.companyId)
|
||||
.then((roster) => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
reset(toFormValues(roster));
|
||||
setSelectedRoster(roster);
|
||||
retryTargetRef.current = null;
|
||||
})
|
||||
|
|
@ -143,7 +161,7 @@ export function useVendorRosterSelection({
|
|||
setLoadErrorMessage(RELOAD_ERROR_MESSAGE);
|
||||
});
|
||||
}
|
||||
}, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]);
|
||||
}, [clearConflict, fetchRosterByCompany, mode, query, selectedRoster]);
|
||||
|
||||
const retryLoad = useCallback(() => {
|
||||
const target = retryTargetRef.current;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { isHTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapRosterConflict,
|
||||
mapVendorCompanyRoster,
|
||||
mapVendorRosterAdditivePatchToBackend,
|
||||
mapVendorRosterToBackend,
|
||||
} from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||
import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict";
|
||||
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper";
|
||||
|
||||
const STALE_MESSAGE =
|
||||
"This company was changed by another session. Reload the latest version and try again.";
|
||||
|
|
@ -97,4 +99,22 @@ export const vendorCompanyRosterApi = {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addTechnicians: async (
|
||||
companyId: string | number,
|
||||
patch: VendorRosterAdditivePatch,
|
||||
): Promise<VendorCompanyRoster> => {
|
||||
try {
|
||||
const data = await apiPatch<unknown>(
|
||||
API_PATHS.vendorCompanyRoster.byCompany(companyId),
|
||||
mapVendorRosterAdditivePatchToBackend(patch),
|
||||
);
|
||||
return mapVendorCompanyRoster(handleApiResponse(data));
|
||||
} catch (error) {
|
||||
if (isHTTPError(error) && error.response.status === 409) {
|
||||
await throwRosterConflict(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,6 +134,28 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
|
|||
return payload;
|
||||
}
|
||||
|
||||
export interface VendorRosterAdditivePatch {
|
||||
rowVersion: string;
|
||||
addTechnicians: unknown[];
|
||||
companyFields?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function mapVendorRosterAdditivePatchToBackend(
|
||||
patch: VendorRosterAdditivePatch,
|
||||
): Record<string, unknown> {
|
||||
const item = asRecord(patch);
|
||||
const payload: Record<string, unknown> = {
|
||||
rowVersion: readString(item, "rowVersion", "RowVersion"),
|
||||
addTechnicians: patch.addTechnicians.map((technician) => {
|
||||
const mapped = mapRosterTechnicianToBackend(technician);
|
||||
delete mapped.id;
|
||||
return mapped;
|
||||
}),
|
||||
};
|
||||
if (patch.companyFields) payload.companyFields = { ...patch.companyFields };
|
||||
return payload;
|
||||
}
|
||||
|
||||
function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export interface SaveVendorCompanyRosterInput {
|
||||
mode: "create" | "update";
|
||||
mode: "create" | "update" | "add";
|
||||
values: VendorCompanyRosterFormValues;
|
||||
companyId?: string | number | null;
|
||||
rowVersion?: string;
|
||||
|
|
@ -65,6 +65,55 @@ export function getSingleStatusOnlyChange(
|
|||
return changed.length === 1 ? changed[0] : null;
|
||||
}
|
||||
|
||||
export interface VendorRosterAdditivePatchInput {
|
||||
rowVersion: string;
|
||||
addTechnicians: Array<{
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
preferredContact?: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
companyFields?: Record<string, string>;
|
||||
}
|
||||
|
||||
function hasTechnicianContent(technician: {
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
tradeSpecialties: string;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
technician.contactName.trim() ||
|
||||
technician.phone.trim() ||
|
||||
technician.email.trim() ||
|
||||
technician.tradeSpecialties.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAdditiveRosterPatch(
|
||||
roster: VendorCompanyRoster | null | undefined,
|
||||
values: VendorCompanyRosterFormValues,
|
||||
rowVersion: string,
|
||||
): VendorRosterAdditivePatchInput | null {
|
||||
const addTechnicians = values.technicians.filter(
|
||||
(technician) => technician.id == null && hasTechnicianContent(technician),
|
||||
);
|
||||
const companyFields: Record<string, string> = {};
|
||||
if (roster) {
|
||||
for (const field of COMPANY_FIELDS) {
|
||||
if (values[field] !== roster[field]) companyFields[field] = values[field];
|
||||
}
|
||||
}
|
||||
if (addTechnicians.length === 0 && Object.keys(companyFields).length === 0) return null;
|
||||
return {
|
||||
rowVersion,
|
||||
addTechnicians,
|
||||
...(Object.keys(companyFields).length > 0 ? { companyFields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSaveVendorCompanyRoster(): UseMutationResult<
|
||||
VendorCompanyRoster,
|
||||
unknown,
|
||||
|
|
@ -108,17 +157,26 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
|||
if (!rowVersion) throw new Error("Row version is required to update");
|
||||
return vendorCompanyRosterApi.update(companyId, values, rowVersion);
|
||||
}
|
||||
if (mode === "add") {
|
||||
if (companyId === undefined || companyId === null || companyId === "") {
|
||||
throw new Error("Company id is required to add technicians");
|
||||
}
|
||||
if (!rowVersion) throw new Error("Row version is required to add technicians");
|
||||
const patch = buildAdditiveRosterPatch(originalRoster, values, rowVersion);
|
||||
if (!patch) throw new Error("Nothing to add to this vendor company");
|
||||
return vendorCompanyRosterApi.addTechnicians(companyId, patch);
|
||||
}
|
||||
return vendorCompanyRosterApi.create(values);
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
if (variables.mode === "update" && variables.companyId) {
|
||||
if ((variables.mode === "update" || variables.mode === "add") && variables.companyId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.vendors.roster("companyId", variables.companyId),
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
variables.mode === "update" ? "Vendor company updated" : "Vendor company created",
|
||||
variables.mode === "create" ? "Vendor company created" : "Vendor company updated",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
|||
|
|
@ -12,15 +12,19 @@ vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||
useVendorCompanyRoster: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useVendorCompanyRoster: () => rosterQueryResult,
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
let rosterQueryResult: {
|
||||
data: VendorCompanyRoster | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: ReturnType<typeof vi.fn>;
|
||||
} = { data: undefined, isLoading: false, isError: false, error: null, refetch: vi.fn() };
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
||||
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
|
||||
}));
|
||||
|
|
@ -36,6 +40,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
|
|||
});
|
||||
|
||||
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const company: VendorFacetCompany = {
|
||||
|
|
@ -81,6 +86,72 @@ const secondRoster: VendorCompanyRoster = {
|
|||
email: "dispatch@metro.test",
|
||||
};
|
||||
|
||||
const rosterWithTechnicians: VendorCompanyRoster = {
|
||||
...roster,
|
||||
technicians: [
|
||||
{
|
||||
id: 11,
|
||||
contactName: "Ray Holt",
|
||||
phone: "(314) 555-0122",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
totalJobs: 0,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
contactName: "Amy Santiago",
|
||||
phone: "(314) 555-0133",
|
||||
email: "",
|
||||
preferredContact: "Text",
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
totalJobs: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newTechnician = {
|
||||
contactName: "Dana Kim",
|
||||
phone: "(314) 555-0144",
|
||||
email: "dana@test.test",
|
||||
preferredContact: "Text" as const,
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function submitValues(
|
||||
technicians: VendorCompanyRosterFormValues["technicians"],
|
||||
): VendorCompanyRosterFormValues {
|
||||
return {
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "1 Main St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians,
|
||||
};
|
||||
}
|
||||
|
||||
function resetRosterQuery(): void {
|
||||
rosterQueryResult = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetRosterQuery();
|
||||
});
|
||||
|
||||
function createClient(): QueryClient {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
|
@ -316,3 +387,220 @@ describe("useVendorRosterForm stale-selection handling", () => {
|
|||
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm additive add flow (SH-250)", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("issues an additive add — never update — when an existing company is selected", async () => {
|
||||
rosterGet.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||
const technician = useController({
|
||||
control: form.control,
|
||||
name: "technicians.0.contactName",
|
||||
});
|
||||
return { form, technician };
|
||||
},
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.form.selectCompany(company);
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.technician.field.onChange("Dana Kim");
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.submit(submitValues([newTechnician]));
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||
const input = saveMutate.mock.calls[0]?.[0] as { mode: string };
|
||||
expect(input.mode).toBe("add");
|
||||
expect(saveMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 5,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: roster,
|
||||
values: expect.objectContaining({ technicians: [newTechnician] }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("still creates via POST when no existing company is matched", () => {
|
||||
const { result } = renderHook(
|
||||
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.submit(submitValues([newTechnician]));
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ mode: "create", companyId: undefined, rowVersion: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks submit when the selected company has nothing to add", async () => {
|
||||
rosterGet.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||
wrapper: makeWrapper(createClient()),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.selectCompany(company);
|
||||
});
|
||||
act(() => {
|
||||
result.current.submit(submitValues([]));
|
||||
});
|
||||
|
||||
expect(saveMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the entered technician and retries with the fresh rowVersion after reload", async () => {
|
||||
rosterGet.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||
const technician = useController({
|
||||
control: form.control,
|
||||
name: "technicians.0.contactName",
|
||||
});
|
||||
return { form, technician };
|
||||
},
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.form.selectCompany(company);
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.technician.field.onChange("Dana Kim");
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.submit(submitValues([newTechnician]));
|
||||
});
|
||||
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
|
||||
|
||||
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
|
||||
await act(async () => {
|
||||
result.current.form.reload();
|
||||
});
|
||||
await waitFor(() => expect(result.current.form.selectedCompanyId).toBe(5));
|
||||
|
||||
act(() => {
|
||||
result.current.form.submit(submitValues([newTechnician]));
|
||||
});
|
||||
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not regress the Edit Vendor reconcile path (mode update)", () => {
|
||||
rosterQueryResult = {
|
||||
data: rosterWithTechnicians,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
|
||||
wrapper: makeWrapper(createClient()),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.submit(submitValues([...rosterWithTechnicians.technicians, newTechnician]));
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: "update",
|
||||
companyId: 5,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: rosterWithTechnicians,
|
||||
values: expect.objectContaining({
|
||||
technicians: [...rosterWithTechnicians.technicians, newTechnician],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm technician autopopulation (SH-246)", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("does not autopopulate technician fields when a company is selected", async () => {
|
||||
rosterGet.mockResolvedValueOnce(rosterWithTechnicians);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const form = useVendorRosterForm({ mode: "create" });
|
||||
const technicians = useController({ control: form.control, name: "technicians" });
|
||||
const name = useController({ control: form.control, name: "name" });
|
||||
return { form, technicians, name };
|
||||
},
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.form.selectCompany(company);
|
||||
});
|
||||
|
||||
const technicians = result.current.technicians.field.value;
|
||||
expect(technicians).toEqual([
|
||||
{
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
]);
|
||||
expect(technicians).not.toContainEqual(expect.objectContaining({ contactName: "Ray Holt" }));
|
||||
expect(technicians).not.toContainEqual(
|
||||
expect.objectContaining({ contactName: "Amy Santiago" }),
|
||||
);
|
||||
expect(result.current.name.field.value).toBe("Gateway Plumbing");
|
||||
expect(result.current.form.selectedCompanyId).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps a typed technician when the company selection is cleared to free text", async () => {
|
||||
rosterGet.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
|
||||
const technician = useController({
|
||||
control: form.control,
|
||||
name: "technicians.0.contactName",
|
||||
});
|
||||
return { form, technician };
|
||||
},
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.form.selectCompany(company);
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.technician.field.onChange("Dana Kim");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.clearSelectedCompany("Draft Vendor");
|
||||
});
|
||||
|
||||
expect(result.current.form.selectedCompanyId).toBeNull();
|
||||
expect(result.current.technician.field.value).toBe("Dana Kim");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import { API_PATHS } from "@/api/api-paths";
|
|||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("ky", () => ({
|
||||
|
|
@ -38,6 +40,7 @@ describe("vendorCompanyRosterApi", () => {
|
|||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPut.mockReset();
|
||||
apiPatch.mockReset();
|
||||
});
|
||||
|
||||
it("fetches by vendorId or companyId with the correct query params", async () => {
|
||||
|
|
@ -121,4 +124,72 @@ describe("vendorCompanyRosterApi", () => {
|
|||
|
||||
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic);
|
||||
});
|
||||
|
||||
it("patches the additive payload without a technician id and without PUT", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||
|
||||
await vendorCompanyRosterApi.addTechnicians(5, {
|
||||
rowVersion: "rv-1",
|
||||
addTechnicians: [
|
||||
{
|
||||
id: 99,
|
||||
contactName: "Adam",
|
||||
phone: "3145550198",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(apiPatch).toHaveBeenCalledWith(
|
||||
API_PATHS.vendorCompanyRoster.byCompany(5),
|
||||
expect.objectContaining({
|
||||
rowVersion: "rv-1",
|
||||
addTechnicians: [expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" })],
|
||||
}),
|
||||
);
|
||||
const body = apiPatch.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("companyFields");
|
||||
expect((body.addTechnicians as Array<Record<string, unknown>>)[0]).not.toHaveProperty("id");
|
||||
expect(apiPut).not.toHaveBeenCalled();
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes companyFields on the additive patch when provided", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } });
|
||||
|
||||
await vendorCompanyRosterApi.addTechnicians(5, {
|
||||
rowVersion: "rv-2",
|
||||
addTechnicians: [
|
||||
{ contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true },
|
||||
],
|
||||
companyFields: { notes: "Updated notes" },
|
||||
});
|
||||
|
||||
expect(apiPatch).toHaveBeenCalledWith(
|
||||
API_PATHS.vendorCompanyRoster.byCompany(5),
|
||||
expect.objectContaining({
|
||||
rowVersion: "rv-2",
|
||||
companyFields: { notes: "Updated notes" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a stale conflict on a 409 from the additive patch", async () => {
|
||||
apiPatch.mockRejectedValueOnce(httpError(409, { message: "rowversion mismatch" }));
|
||||
|
||||
await expect(
|
||||
vendorCompanyRosterApi.addTechnicians(5, {
|
||||
rowVersion: "rv-1",
|
||||
addTechnicians: [
|
||||
{ contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true },
|
||||
],
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
if (!isVendorRosterConflictError(error)) return false;
|
||||
return error.conflict.kind === "stale";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
234
src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
vendored
Normal file
234
src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rosterAdd = vi.fn();
|
||||
const rosterUpdate = vi.fn();
|
||||
const rosterCreate = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||
vendorCompanyRosterApi: {
|
||||
addTechnicians: (...args: unknown[]) => rosterAdd(...args),
|
||||
update: (...args: unknown[]) => rosterUpdate(...args),
|
||||
create: (...args: unknown[]) => rosterCreate(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/vendors/api/vendors-api", () => ({
|
||||
vendorsApi: { update: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import {
|
||||
buildAdditiveRosterPatch,
|
||||
useSaveVendorCompanyRoster,
|
||||
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
|
||||
const roster: VendorCompanyRoster = {
|
||||
companyId: 5,
|
||||
rowVersion: "rv-1",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "1 Main St",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
googleMapsUrl: "",
|
||||
notes: "Preferred vendor",
|
||||
technicians: [
|
||||
{
|
||||
id: 11,
|
||||
contactName: "Ray Holt",
|
||||
phone: "(314) 555-0122",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
totalJobs: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newTechnician = {
|
||||
contactName: "Dana Kim",
|
||||
phone: "(314) 555-0144",
|
||||
email: "dana@test.test",
|
||||
preferredContact: "Text" as const,
|
||||
tradeSpecialties: "HVAC",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function formValues(
|
||||
overrides: Partial<VendorCompanyRosterFormValues> = {},
|
||||
): VendorCompanyRosterFormValues {
|
||||
return {
|
||||
name: roster.name,
|
||||
companyPhone: roster.companyPhone,
|
||||
email: roster.email,
|
||||
address: roster.address,
|
||||
city: roster.city,
|
||||
state: roster.state,
|
||||
zip: roster.zip,
|
||||
googleMapsUrl: roster.googleMapsUrl,
|
||||
notes: roster.notes,
|
||||
technicians: [newTechnician],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAdditiveRosterPatch", () => {
|
||||
it("carries only the newly entered technician, never a full roster snapshot", () => {
|
||||
const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1");
|
||||
|
||||
expect(patch).not.toBeNull();
|
||||
expect(patch?.addTechnicians).toEqual([newTechnician]);
|
||||
expect(patch?.addTechnicians[0]).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("ignores blank technician rows", () => {
|
||||
const patch = buildAdditiveRosterPatch(
|
||||
roster,
|
||||
formValues({
|
||||
technicians: [
|
||||
{
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
newTechnician,
|
||||
],
|
||||
}),
|
||||
"rv-1",
|
||||
);
|
||||
|
||||
expect(patch?.addTechnicians).toEqual([newTechnician]);
|
||||
});
|
||||
|
||||
it("sends only changed company fields", () => {
|
||||
const patch = buildAdditiveRosterPatch(
|
||||
roster,
|
||||
formValues({ notes: "New notes", address: "2 Oak Ave" }),
|
||||
"rv-1",
|
||||
);
|
||||
|
||||
expect(patch?.companyFields).toEqual({ notes: "New notes", address: "2 Oak Ave" });
|
||||
});
|
||||
|
||||
it("omits companyFields entirely when nothing changed", () => {
|
||||
const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1");
|
||||
|
||||
expect(patch).not.toHaveProperty("companyFields");
|
||||
});
|
||||
|
||||
it("returns null when there is nothing to add or change", () => {
|
||||
const patch = buildAdditiveRosterPatch(
|
||||
roster,
|
||||
formValues({
|
||||
technicians: [
|
||||
{
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"rv-1",
|
||||
);
|
||||
|
||||
expect(patch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createWrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSaveVendorCompanyRoster routing", () => {
|
||||
beforeEach(() => {
|
||||
rosterAdd.mockReset();
|
||||
rosterUpdate.mockReset();
|
||||
rosterCreate.mockReset();
|
||||
});
|
||||
|
||||
it("add mode issues the additive patch and never the reconcile PUT", async () => {
|
||||
rosterAdd.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
mode: "add",
|
||||
values: formValues(),
|
||||
companyId: 5,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: roster,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(rosterAdd).toHaveBeenCalledTimes(1);
|
||||
expect(rosterAdd).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
rowVersion: "rv-1",
|
||||
addTechnicians: [newTechnician],
|
||||
}),
|
||||
);
|
||||
expect(rosterUpdate).not.toHaveBeenCalled();
|
||||
expect(rosterCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("update mode still issues the full reconcile PUT", async () => {
|
||||
rosterUpdate.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
mode: "update",
|
||||
values: formValues({
|
||||
technicians: [{ id: 11, ...newTechnician }, newTechnician],
|
||||
}),
|
||||
companyId: 5,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: roster,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(rosterUpdate).toHaveBeenCalledWith(5, expect.anything(), "rv-1");
|
||||
expect(rosterAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("create mode still issues the POST", async () => {
|
||||
rosterCreate.mockResolvedValueOnce(roster);
|
||||
const { result } = renderHook(() => useSaveVendorCompanyRoster(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ mode: "create", values: formValues() });
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(rosterCreate).toHaveBeenCalledTimes(1);
|
||||
expect(rosterAdd).not.toHaveBeenCalled();
|
||||
expect(rosterUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue