Compare commits

...

2 commits

Author SHA1 Message Date
Alexandre Brandizzi
c0e484275d fix(workorders): say Site dialog saves to the work order only when the site did not load
When the site detail request fails, or the user types before it loads,
the dialog falls back to saving this work order only and never calls
updateContactInfo. The POC helper text still said contacts and notes were
saved to the site record, so a dispatcher could believe every work order
at the site now had the new contact. The helper text now follows the
site-record sync state and says the change applies to this work order
only whenever that fallback is active, including after a later refetch
succeeds.
2026-09-25 13:39:25 -03:00
Alexandre Brandizzi
7efc9e8914 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.
2026-09-25 13:12:27 -03:00
6 changed files with 88 additions and 12 deletions

View file

@ -10,12 +10,23 @@ type SiteDialogPocSectionProps = {
primaryRequired?: boolean;
/** Edits go to the site record rather than only this work order. */
editsSiteRecord?: boolean;
/** The site record did not load, so Save writes this work order only. */
siteRecordUnavailable?: boolean;
onPocNameChange: (value: string) => void;
onPocPhoneChange: (value: string) => void;
onPocNotesChange: (value: string) => void;
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
};
function helperText(editsSiteRecord: boolean, siteRecordUnavailable: boolean): string {
if (!editsSiteRecord) {
return "Auto-filled from site records when empty. Additional contacts persist on save.";
}
return siteRecordUnavailable
? "Site record unavailable — changes apply to this work order only."
: "Contacts and notes are saved to the site record and apply to all its work orders.";
}
export function SiteDialogPocSection({
pocName,
pocPhone,
@ -24,6 +35,7 @@ export function SiteDialogPocSection({
showPrimaryErrors = false,
primaryRequired = false,
editsSiteRecord = false,
siteRecordUnavailable = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
@ -41,11 +53,7 @@ export function SiteDialogPocSection({
onPocPhoneChange={onPocPhoneChange}
onPocNotesChange={onPocNotesChange}
onAdditionalContactsChange={onAdditionalContactsChange}
helperText={
editsSiteRecord
? "Contacts and notes are saved to the site record and apply to all its work orders."
: "Auto-filled from site records when empty. Additional contacts persist on save."
}
helperText={helperText(editsSiteRecord, siteRecordUnavailable)}
/>
);
}

View file

@ -139,6 +139,7 @@ export function SiteDialog({
showPrimaryErrors={s.requiresPoc && s.showErrors}
primaryRequired={s.requiresPoc}
editsSiteRecord={s.editsSiteRecord}
siteRecordUnavailable={s.siteRecordUnavailable}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}

View file

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

View file

@ -95,6 +95,9 @@ export function useSiteDialogState({
locationDetailError: liveLocationDetailError,
fields,
});
// The site failed to load (or the user typed before it did), so Save writes this work order only.
const siteRecordUnavailable =
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
const updateSite = useUpdateSiteContactInfo();
const pocMissing = !pn.trim() || !pp.trim();
const requiresPoc = createMode || editsSiteRecord;
@ -166,6 +169,7 @@ export function useSiteDialogState({
saving: updateSite.isPending,
siteRecordLoading: siteRecord.loading,
editsSiteRecord,
siteRecordUnavailable,
requiresPoc,
setPn: fields.setPnDirty,
setPp: fields.setPpDirty,

View file

@ -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<number | undefined>(undefined);
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(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,

View file

@ -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();
@ -33,10 +34,14 @@ const dallas: Location = {
const sites = [{ id: "1", name: "DAL1" }];
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]> = {}) {
const onSave = vi.fn();
const onOpenChange = vi.fn();
renderWithProviders(
const { queryClient } = renderWithProviders(
<SiteDialog
open
onOpenChange={onOpenChange}
@ -51,7 +56,7 @@ function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {})
/>,
{ withAuth: false },
);
return { onSave, onOpenChange };
return { onSave, onOpenChange, queryClient };
}
/** The main contact's field; additional contact rows reuse the same placeholders after it. */
@ -86,6 +91,8 @@ describe("Work order Site dialog edits the site record", () => {
expect(screen.getByTitle("Site location map")).toBeInTheDocument();
expect(screen.getByText("(214) 555-0100")).toBeInTheDocument();
expect(screen.getByText("dal1@acme.test")).toBeInTheDocument();
expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument();
expect(screen.queryByText(SITE_UNAVAILABLE_COPY)).not.toBeInTheDocument();
});
it("locks the contact fields until the site loads, so typed input is never replaced", async () => {
@ -111,6 +118,39 @@ 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());
expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument();
expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument();
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");
// The typed input stays on the work order, so the copy still says so after the refetch.
expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument();
expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument();
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");