From 301fe44b0c507b6653dae5f2e68d8c7772d2bceb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:52:03 -0300 Subject: [PATCH] fix(workorders): Site dialog loads legacy site contacts and locks fields while saving A site saved before the contacts list existed maps with no contacts, only the legacy contact/phone fields. The site-record sync read the contacts list alone, so the dialog blanked the POC, blocked a notes-only Save until the contact was retyped, and when the work order had no POC the legacy autofill made the diff report a contact edit nobody made. The legacy contact is now the site's main contact for display, baseline and request. Save sends the values it had when clicked, so the dialog's fields are now disabled while the site update is in flight instead of accepting edits that neither save would include. The site-update mutation already toasts its own failure, so it sets meta.suppressErrorToast and the global mutation toast no longer repeats it. --- .../list/table/cells/site-dialog.tsx | 3 +- .../list/table/cells/use-site-record-sync.ts | 21 +++- .../use-cases/use-update-site-contact-info.ts | 2 + .../site-dialog-site-record.test.tsx | 108 +++++++++++++++++- 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 6ee17752..46854cb8 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -85,7 +85,8 @@ export function SiteDialog({ Select a site and edit its point of contact -
+ {/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */} +
Site 0) { + return location.contacts; + } + const name = location.contact ?? ""; + const phone = location.phone ?? ""; + return name.trim() || phone.trim() ? [{ name, phone }] : []; +} + /** The site record's contacts and notes, in the dialog's field shape. */ function siteRecordValues(location: Location) { - const [main, ...others] = location.contacts ?? []; + const [main, ...others] = siteContacts(location); return { primaryId: main?.id, pn: main?.name ?? "", diff --git a/src/domain/locations/use-cases/use-update-site-contact-info.ts b/src/domain/locations/use-cases/use-update-site-contact-info.ts index 4f218a42..d646430c 100644 --- a/src/domain/locations/use-cases/use-update-site-contact-info.ts +++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts @@ -29,5 +29,7 @@ export function useUpdateSiteContactInfo(): UseMutationResult< onError: (error: Error) => { toast.error(error.message || "Failed to update the site"); }, + // The toast above is this failure's only message; the global mutation toast would repeat it. + meta: { suppressErrorToast: true }, }); } diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx index edddda9b..b4a67ced 100644 --- a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -1,12 +1,22 @@ +import type { QueryClient } from "@tanstack/react-query"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; import type { Location } from "@/domain/locations/types/location"; import { queryKeys } from "@/infra/query-key/query-key"; +import { createAppQueryClient } from "@/lib/query/query-client"; import { renderWithProviders } from "@/test/test-utils"; const getById = vi.fn(); const updateContactInfo = vi.fn(); +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +vi.mock("react-toastify", () => ({ toast: toastMocks })); vi.mock("@/domain/locations/api/locations-api", () => ({ locationsApi: { @@ -38,7 +48,10 @@ const SITE_RECORD_COPY = "Contacts and notes are saved to the site record and apply to all its work orders."; const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; -function renderDialog(overrides: Partial[0]> = {}) { +function renderDialog( + overrides: Partial[0]> = {}, + options: { queryClient?: QueryClient } = {}, +) { const onSave = vi.fn(); const onOpenChange = vi.fn(); const { queryClient } = renderWithProviders( @@ -54,7 +67,7 @@ function renderDialog(overrides: Partial[0]> = {}) onSave={onSave} {...overrides} />, - { withAuth: false }, + { withAuth: false, ...options }, ); return { onSave, onOpenChange, queryClient }; } @@ -72,6 +85,7 @@ describe("Work order Site dialog edits the site record", () => { beforeEach(() => { getById.mockReset(); updateContactInfo.mockReset(); + toastMocks.error.mockReset(); getById.mockResolvedValue(dallas); }); @@ -277,6 +291,96 @@ describe("Work order Site dialog edits the site record", () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + it("shows one error toast when the site update fails, not the global one as well", async () => { + updateContactInfo.mockRejectedValue(new Error("Forbidden")); + renderDialog({}, { queryClient: createAppQueryClient() }); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(saveButton()).toBeEnabled()); + expect(toastMocks.error).toHaveBeenCalledTimes(1); + expect(toastMocks.error).toHaveBeenCalledWith("Forbidden"); + }); + + it("locks the fields while the site update is in flight, so no edit is lost", async () => { + let finishSiteUpdate: () => void = () => {}; + updateContactInfo.mockReturnValue( + new Promise((resolve) => { + finishSiteUpdate = resolve; + }), + ); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await screen.findByRole("button", { name: /saving/i }); + expect(primaryField("POC name")).toBeDisabled(); + expect(primaryField("POC phone")).toBeDisabled(); + expect(primaryField("Notes…")).toBeDisabled(); + expect(screen.getByRole("button", { name: /add point of contact/i })).toBeDisabled(); + + finishSiteUpdate(); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ pocNotes: "" }); + }); + + describe("on a site saved before the contacts list existed", () => { + const legacyDallas: Location = { + ...dallas, + phone: "(421) 433-0050", + contact: "Legacy Lee", + contacts: undefined, + }; + + beforeEach(() => { + getById.mockResolvedValue(legacyDallas); + }); + + it("shows the site's legacy contact and saves a notes-only edit without retyping it", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog(); + + expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument(); + expect(primaryField("POC phone")).toHaveValue("(421) 433-0050"); + expect(saveButton()).toBeDisabled(); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument(); + // The legacy contact becomes the site's first contact row, unchanged. + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: [{ name: "Legacy Lee", phone: "(421) 433-0050" }], + notes: "Gate code 9999", + }); + // Only the notes were edited, so the work order keeps its own contact override. + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "Someone Else", + pocPhone: "(000) 000-0000", + pocNotes: "", + }); + }); + + it("does not report a contact edit the user never made when the work order had no POC", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog({ pocName: "", pocPhone: "" }); + + expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument(); + expect(saveButton()).toBeDisabled(); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ pocName: "", pocPhone: "", pocNotes: "" }); + }); + }); + it("requires the first contact before saving", async () => { const { onSave } = renderDialog(); await screen.findByDisplayValue("Jane");