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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 19:52:03 -03:00
parent 53dcb6af9e
commit 301fe44b0c
4 changed files with 129 additions and 5 deletions

View file

@ -85,7 +85,8 @@ export function SiteDialog({
Select a site and edit its point of contact
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */}
<fieldset disabled={viewOnly || s.saving} className="mt-3 space-y-3 border-0 p-0">
<div>
<FieldLabel required={s.requiresPoc}>Site</FieldLabel>
<WizardFieldSelect

View file

@ -1,5 +1,9 @@
import { useEffect, useState } from "react";
import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location";
import type {
Location,
LocationContact,
SiteContactInfoInput,
} from "@/domain/locations/types/location";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
type SiteRecordFields = {
@ -99,9 +103,22 @@ export function workOrderPocAfterSiteSave(
};
}
/**
* The site's contacts in order. A site saved before the contacts list existed has none; its
* contact lives only in the legacy `contact`/`phone` fields, so that one is the main contact.
*/
function siteContacts(location: Location): LocationContact[] {
if (location.contacts && location.contacts.length > 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 ?? "",

View file

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

View file

@ -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<Parameters<typeof SiteDialog>[0]> = {}) {
function renderDialog(
overrides: Partial<Parameters<typeof SiteDialog>[0]> = {},
options: { queryClient?: QueryClient } = {},
) {
const onSave = vi.fn();
const onOpenChange = vi.fn();
const { queryClient } = renderWithProviders(
@ -54,7 +67,7 @@ function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[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<void>((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");