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");