mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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.
This commit is contained in:
parent
ad500753e1
commit
7efc9e8914
3 changed files with 59 additions and 7 deletions
|
|
@ -51,6 +51,8 @@ export function useSiteDialogFormFields({
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [contactsDirty, setContactsDirty] = 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(
|
const [baselineHadContacts, setBaselineHadContacts] = useState(
|
||||||
() => additionalContacts.length > 0,
|
() => additionalContacts.length > 0,
|
||||||
);
|
);
|
||||||
|
|
@ -62,6 +64,7 @@ export function useSiteDialogFormFields({
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setContactsDirty(false);
|
setContactsDirty(false);
|
||||||
|
setUserEdited(false);
|
||||||
setShowErrors(false);
|
setShowErrors(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
@ -92,6 +95,7 @@ export function useSiteDialogFormFields({
|
||||||
if (siteChanged) {
|
if (siteChanged) {
|
||||||
setExtraContacts([]);
|
setExtraContacts([]);
|
||||||
setContactsDirty(true);
|
setContactsDirty(true);
|
||||||
|
setUserEdited(false);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
if (createMode) {
|
if (createMode) {
|
||||||
setPn("");
|
setPn("");
|
||||||
|
|
@ -105,6 +109,7 @@ export function useSiteDialogFormFields({
|
||||||
|
|
||||||
const markPocDirty = (apply: () => void) => {
|
const markPocDirty = (apply: () => void) => {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
setUserEdited(true);
|
||||||
apply();
|
apply();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -118,6 +123,7 @@ export function useSiteDialogFormFields({
|
||||||
pocFilledFor,
|
pocFilledFor,
|
||||||
dirty,
|
dirty,
|
||||||
contactsDirty,
|
contactsDirty,
|
||||||
|
userEdited,
|
||||||
resetVersion,
|
resetVersion,
|
||||||
baselineHadContacts,
|
baselineHadContacts,
|
||||||
showErrors,
|
showErrors,
|
||||||
|
|
@ -136,6 +142,7 @@ export function useSiteDialogFormFields({
|
||||||
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
|
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
|
||||||
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
|
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
setUserEdited(true);
|
||||||
setContactsDirty(true);
|
setContactsDirty(true);
|
||||||
setExtraContacts(contacts);
|
setExtraContacts(contacts);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ type SiteRecordFields = {
|
||||||
pp: string;
|
pp: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
extraContacts: WorkOrderAdditionalContact[];
|
extraContacts: WorkOrderAdditionalContact[];
|
||||||
|
/** The user typed into the contact fields since the last reset or site switch. */
|
||||||
|
userEdited: boolean;
|
||||||
resetVersion: number;
|
resetVersion: number;
|
||||||
setPn: (value: string) => void;
|
setPn: (value: string) => void;
|
||||||
setPp: (value: string) => void;
|
setPp: (value: string) => void;
|
||||||
|
|
@ -105,7 +107,7 @@ export function useSiteRecordSync({
|
||||||
const [syncedFor, setSyncedFor] = useState("");
|
const [syncedFor, setSyncedFor] = useState("");
|
||||||
const [primaryId, setPrimaryId] = useState<number | undefined>(undefined);
|
const [primaryId, setPrimaryId] = useState<number | undefined>(undefined);
|
||||||
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
||||||
const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
|
const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSyncedFor("");
|
setSyncedFor("");
|
||||||
|
|
@ -113,7 +115,9 @@ export function useSiteRecordSync({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const [main, ...others] = locationDetail.contacts ?? [];
|
const [main, ...others] = locationDetail.contacts ?? [];
|
||||||
|
|
@ -137,15 +141,27 @@ export function useSiteRecordSync({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setSyncedFor(locId);
|
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 request = toRequest(primaryId, fields);
|
||||||
const synced = enabled && syncedFor !== "" && syncedFor === locId;
|
const synced = enabled && syncedFor !== "" && syncedFor === locId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
synced,
|
synced,
|
||||||
// Until the site loads the fields do not reflect it; a failed load falls back to the work order.
|
// 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,
|
// 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),
|
siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline),
|
||||||
edits: diffEdits(synced ? baseline : null, request),
|
edits: diffEdits(synced ? baseline : null, request),
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
|
import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const getById = vi.fn();
|
const getById = vi.fn();
|
||||||
|
|
@ -36,7 +37,7 @@ const sites = [{ id: "1", name: "DAL1" }];
|
||||||
function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {}) {
|
function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {}) {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
const onOpenChange = vi.fn();
|
const onOpenChange = vi.fn();
|
||||||
renderWithProviders(
|
const { queryClient } = renderWithProviders(
|
||||||
<SiteDialog
|
<SiteDialog
|
||||||
open
|
open
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
|
|
@ -51,7 +52,7 @@ function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {})
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
return { onSave, onOpenChange };
|
return { onSave, onOpenChange, queryClient };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The main contact's field; additional contact rows reuse the same placeholders after it. */
|
/** 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();
|
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 () => {
|
it("keeps Save disabled until something changes", async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
await screen.findByDisplayValue("Jane");
|
await screen.findByDisplayValue("Jane");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue