From 7efc9e891431d05ca2ea3e5afedd82302ae00c3f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:12:27 -0300 Subject: [PATCH] fix(workorders): never let a late site load replace typed Site dialog input The contact lock only covered a pending load. After a failed getById the fields unlock, and a later successful refetch (focus, invalidation) still ran the apply effect and overwrote whatever the dispatcher had typed. The apply effect now skips once the user has typed since the last reset or site switch, so the dialog stays on the work order's values and saves them to the work order only; the site's unseen notes and contacts are never written back. Picking another site still clears the marker and adopts it. --- .../cells/use-site-dialog-form-fields.ts | 7 ++++ .../list/table/cells/use-site-record-sync.ts | 26 ++++++++++++--- .../site-dialog-site-record.test.tsx | 33 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts index a5c585f8..db1db3e7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -51,6 +51,8 @@ export function useSiteDialogFormFields({ const [pocFilledFor, setPocFilledFor] = useState(""); const [dirty, setDirty] = useState(false); const [contactsDirty, setContactsDirty] = useState(false); + // The user typed into the contact fields since the last reset or site switch. + const [userEdited, setUserEdited] = useState(false); const [baselineHadContacts, setBaselineHadContacts] = useState( () => additionalContacts.length > 0, ); @@ -62,6 +64,7 @@ export function useSiteDialogFormFields({ if (!open) { setDirty(false); setContactsDirty(false); + setUserEdited(false); setShowErrors(false); } }, [open]); @@ -92,6 +95,7 @@ export function useSiteDialogFormFields({ if (siteChanged) { setExtraContacts([]); setContactsDirty(true); + setUserEdited(false); setDirty(true); if (createMode) { setPn(""); @@ -105,6 +109,7 @@ export function useSiteDialogFormFields({ const markPocDirty = (apply: () => void) => { setDirty(true); + setUserEdited(true); apply(); }; @@ -118,6 +123,7 @@ export function useSiteDialogFormFields({ pocFilledFor, dirty, contactsDirty, + userEdited, resetVersion, baselineHadContacts, showErrors, @@ -136,6 +142,7 @@ export function useSiteDialogFormFields({ setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)), setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => { setDirty(true); + setUserEdited(true); setContactsDirty(true); setExtraContacts(contacts); }, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts index 07a482f5..33b09eb0 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -7,6 +7,8 @@ type SiteRecordFields = { pp: string; notes: string; extraContacts: WorkOrderAdditionalContact[]; + /** The user typed into the contact fields since the last reset or site switch. */ + userEdited: boolean; resetVersion: number; setPn: (value: string) => void; setPp: (value: string) => void; @@ -105,7 +107,7 @@ export function useSiteRecordSync({ const [syncedFor, setSyncedFor] = useState(""); const [primaryId, setPrimaryId] = useState(undefined); const [baseline, setBaseline] = useState(null); - const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; useEffect(() => { setSyncedFor(""); @@ -113,7 +115,9 @@ export function useSiteRecordSync({ useEffect(() => { const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; - if (!enabled || !open || !locId || !detailMatches || syncedFor === locId) { + // Input typed while the site was unavailable (a failed load) is never replaced by a late + // response: the dialog stays on the work order's values and saves them to the work order only. + if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) { return; } const [main, ...others] = locationDetail.contacts ?? []; @@ -137,15 +141,27 @@ export function useSiteRecordSync({ }), ); setSyncedFor(locId); - }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]); + }, [ + enabled, + open, + locId, + locationDetail, + syncedFor, + userEdited, + setPn, + setPp, + setNotes, + setExtraContacts, + ]); const request = toRequest(primaryId, fields); const synced = enabled && syncedFor !== "" && syncedFor === locId; return { synced, - // Until the site loads the fields do not reflect it; a failed load falls back to the work order. - loading: enabled && Boolean(locId) && !synced && !locationDetailError, + // Until the site loads the fields do not reflect it; a failed load falls back to the work order, + // which it keeps once the user has typed, even if a later refetch succeeds. + loading: enabled && Boolean(locId) && !synced && !locationDetailError && !userEdited, siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline), edits: diffEdits(synced ? baseline : null, request), request, 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 c3172331..79dfb6a0 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 @@ -2,6 +2,7 @@ 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 { renderWithProviders } from "@/test/test-utils"; const getById = vi.fn(); @@ -36,7 +37,7 @@ const sites = [{ id: "1", name: "DAL1" }]; function renderDialog(overrides: Partial[0]> = {}) { const onSave = vi.fn(); const onOpenChange = vi.fn(); - renderWithProviders( + const { queryClient } = renderWithProviders( [0]> = {}) />, { withAuth: false }, ); - return { onSave, onOpenChange }; + return { onSave, onOpenChange, queryClient }; } /** The main contact's field; additional contact rows reuse the same placeholders after it. */ @@ -111,6 +112,34 @@ describe("Work order Site dialog edits the site record", () => { expect(saveButton()).toBeEnabled(); }); + it("keeps a POC typed after a failed site load when a later refetch succeeds", async () => { + getById.mockRejectedValueOnce(new Error("Network error")); + updateContactInfo.mockResolvedValue(undefined); + const { onSave, queryClient } = renderDialog(); + + await waitFor(() => expect(primaryField("POC name")).toBeEnabled()); + fireEvent.change(primaryField("POC name"), { target: { value: "John" } }); + + await queryClient.refetchQueries({ queryKey: queryKeys.locations.detail("1") }); + await waitFor(() => expect(getById).toHaveBeenCalledTimes(2)); + await screen.findByText("(214) 555-0100"); + + expect(primaryField("POC name")).toHaveValue("John"); + expect(primaryField("POC name")).toBeEnabled(); + expect(primaryField("POC phone")).toHaveValue("(000) 000-0000"); + expect(screen.queryByDisplayValue("Jane")).not.toBeInTheDocument(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "John", + pocPhone: "(000) 000-0000", + pocNotes: "Old work order note", + }); + // The site's notes and other contacts were never shown, so they are not written back. + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + it("keeps Save disabled until something changes", async () => { renderDialog(); await screen.findByDisplayValue("Jane");