diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 89bd402f..8bbfdfb8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -419,6 +419,13 @@ test.describe("Vendor directory prototype parity", () => { }), ]), ); + const submittedTechnicians = Array.isArray(state.updatedBody?.technicians) + ? (state.updatedBody.technicians as Array>) + : []; + const newTechnician = submittedTechnicians.find( + (technician) => technician.contactName === "New Technician", + ); + expect(newTechnician?.preferredContact).toBeUndefined(); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index c1dfc39f..cce00e18 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -65,6 +65,7 @@ export function useVendorRosterSelection({ const retryTargetRef = useRef< { kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null >(null); + const requestIdRef = useRef(0); const fetchRosterByCompany = useCallback( async (id: string | number): Promise => @@ -78,20 +79,24 @@ export function useVendorRosterSelection({ const selectCompany = useCallback( async (company: VendorFacetCompany | null) => { if (!company || company.companyId == null) { + requestIdRef.current += 1; setSelectedRoster(null); setLoadErrorMessage(null); retryTargetRef.current = null; return; } + const requestId = (requestIdRef.current += 1); setLoadErrorMessage(null); retryTargetRef.current = { kind: "select", company }; try { const roster = await fetchRosterByCompany(company.companyId); + if (requestIdRef.current !== requestId) return; reset(toFormValues(roster)); setSelectedRoster(roster); clearConflict(); retryTargetRef.current = null; } catch { + if (requestIdRef.current !== requestId) return; setLoadErrorMessage(SELECT_ERROR_MESSAGE); } }, @@ -100,6 +105,7 @@ export function useVendorRosterSelection({ const clearSelectedCompany = useCallback( (nextName = "") => { + requestIdRef.current += 1; if (selectedRoster) { reset({ ...emptyVendorCompanyRosterForm, name: nextName }); } @@ -112,11 +118,13 @@ export function useVendorRosterSelection({ ); const reload = useCallback(() => { + const requestId = (requestIdRef.current += 1); clearConflict(); setLoadErrorMessage(null); retryTargetRef.current = { kind: "reload" }; if (mode === "update") { void query.refetch().then((result) => { + if (requestIdRef.current !== requestId) return; if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE); else retryTargetRef.current = null; }); @@ -125,11 +133,15 @@ export function useVendorRosterSelection({ if (selectedRoster?.companyId != null) { void fetchRosterByCompany(selectedRoster.companyId) .then((roster) => { + if (requestIdRef.current !== requestId) return; reset(toFormValues(roster)); setSelectedRoster(roster); retryTargetRef.current = null; }) - .catch(() => setLoadErrorMessage(RELOAD_ERROR_MESSAGE)); + .catch(() => { + if (requestIdRef.current !== requestId) return; + setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + }); } }, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]); @@ -141,6 +153,7 @@ export function useVendorRosterSelection({ }, [reload, selectCompany]); const resetSelection = useCallback(() => { + requestIdRef.current += 1; setSelectedRoster(null); setLoadErrorMessage(null); retryTargetRef.current = null; diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index f4e06cd9..32f91675 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -343,7 +343,6 @@ function TechniciansFieldArray({ contactName: "", phone: "", email: "", - preferredContact: "Phone", tradeSpecialties: "", isActive: true, }) diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index d4255a0b..e083f352 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -61,6 +61,22 @@ const roster: VendorCompanyRoster = { technicians: [], }; +const secondCompany: VendorFacetCompany = { + ...company, + companyId: 6, + name: "Metro HVAC", + companyPhone: "(314) 555-0110", + email: "dispatch@metro.test", +}; + +const secondRoster: VendorCompanyRoster = { + ...roster, + companyId: 6, + name: "Metro HVAC", + companyPhone: "(314) 555-0110", + email: "dispatch@metro.test", +}; + function createClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { retry: false } } }); } @@ -71,6 +87,17 @@ function makeWrapper(client: QueryClient) { }; } +function deferRoster(): { + promise: Promise; + resolve: (value: VendorCompanyRoster) => void; +} { + let resolve!: (value: VendorCompanyRoster) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + describe("useVendorRosterForm load-error handling", () => { beforeEach(() => { rosterGet.mockReset(); @@ -168,3 +195,81 @@ describe("useVendorRosterForm load-error handling", () => { expect(result.current.loadErrorMessage).not.toContain("10.0.0.1"); }); }); + +describe("useVendorRosterForm stale-selection handling", () => { + beforeEach(() => { + rosterGet.mockReset(); + }); + + it("keeps the newest company roster when two lookups resolve out of order", async () => { + const first = deferRoster(); + const second = deferRoster(); + rosterGet.mockImplementationOnce(() => first.promise); + rosterGet.mockImplementationOnce(() => second.promise); + + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const name = useController({ control: form.control, name: "name" }); + return { form, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + let firstSelect!: Promise; + let secondSelect!: Promise; + act(() => { + firstSelect = result.current.form.selectCompany(company); + }); + act(() => { + secondSelect = result.current.form.selectCompany(secondCompany); + }); + + await act(async () => { + second.resolve(secondRoster); + await secondSelect; + }); + await act(async () => { + first.resolve(roster); + await firstSelect; + }); + + expect(result.current.form.selectedCompanyId).toBe(6); + expect(result.current.name.field.value).toBe("Metro HVAC"); + }); + + it("keeps a free-text draft when a pending lookup resolves after clearing to free text", async () => { + const lookup = deferRoster(); + rosterGet.mockImplementationOnce(() => lookup.promise); + + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const name = useController({ control: form.control, name: "name" }); + return { form, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + let pendingSelect!: Promise; + act(() => { + pendingSelect = result.current.form.selectCompany(company); + }); + + act(() => { + result.current.form.clearSelectedCompany("Draft Vendor"); + }); + await act(async () => { + result.current.name.field.onChange("Draft Vendor"); + }); + expect(result.current.name.field.value).toBe("Draft Vendor"); + + await act(async () => { + lookup.resolve(roster); + await pendingSelect; + }); + + expect(result.current.form.selectedCompanyId).toBeNull(); + expect(result.current.name.field.value).toBe("Draft Vendor"); + }); +});