Skip to content

Commit 4a8620b

Browse files
emoss08claude
andcommitted
fix(client): open the create panel blank after an edit panel loaded a record
A route creates one useForm and hands it to whichever of FormCreatePanel or FormEditPanel the current mode selects, so the two panels share a form instance. FormEditPanel loads a record with reset(row), and react-hook-form's reset REPLACES defaultValues with what it is given. FormCreatePanel then reset()s to those defaults, which means opening a create panel after an edit panel pre-filled it with the record that was just edited — including its id. rememberPristineDefaults records the defaults the route gave its form the first time either panel sees it. That happens during render, before either panel's reset effect runs, so whichever panel mounts first captures the route's own defaults rather than a loaded record. The create panel resets to those. Deliberately not reset(row, { keepDefaultValues: true }) in the edit panel, which is the shorter fix: FormSaveDock reads isDirty and dirtyFields, and keeping the blank defaults would make an edit form read as dirty the moment it loads a record. The edit panel should own defaultValues; it is the create panel that needs a different baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5ac4917 commit 4a8620b

4 files changed

Lines changed: 213 additions & 6 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { ReactNode } from "react";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4+
import { render, waitFor } from "@testing-library/react";
5+
import { useForm, type UseFormReturn } from "react-hook-form";
6+
import { FormCreatePanel } from "@/components/form-create-panel";
7+
import { FormEditPanel } from "@/components/form-edit-panel";
8+
9+
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
10+
11+
type ChargeForm = {
12+
code: string;
13+
description: string;
14+
status: string;
15+
};
16+
17+
const PRISTINE: ChargeForm = { code: "", description: "", status: "Active" };
18+
const ROW = {
19+
id: "acc_1",
20+
code: "DET",
21+
description: "Detention",
22+
status: "Inactive",
23+
} as const;
24+
25+
// Mirrors the shape every route using these panels has: ONE useForm, handed to whichever
26+
// panel `mode` selects. See routes/accessorial-charge/_components/accessorial-charge-panel.
27+
function SharedFormPanels({
28+
mode,
29+
onForm,
30+
}: {
31+
mode: "create" | "edit";
32+
onForm: (form: UseFormReturn<ChargeForm>) => void;
33+
}) {
34+
const form = useForm<ChargeForm>({ defaultValues: PRISTINE });
35+
onForm(form);
36+
37+
if (mode === "edit") {
38+
return (
39+
<FormEditPanel<ChargeForm, typeof ROW & Record<string, unknown>>
40+
open
41+
onOpenChange={() => undefined}
42+
row={ROW as unknown as typeof ROW & Record<string, unknown>}
43+
form={form}
44+
url="/accessorial-charges/"
45+
queryKey="accessorial-charge-list"
46+
title="Accessorial Charge"
47+
formComponent={null}
48+
/>
49+
);
50+
}
51+
52+
return (
53+
<FormCreatePanel<ChargeForm, typeof ROW>
54+
open
55+
onOpenChange={() => undefined}
56+
form={form}
57+
url="/accessorial-charges/"
58+
queryKey="accessorial-charge-list"
59+
title="Accessorial Charge"
60+
formComponent={null}
61+
/>
62+
);
63+
}
64+
65+
function wrapper(client: QueryClient) {
66+
return ({ children }: { children: ReactNode }) => (
67+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
68+
);
69+
}
70+
71+
describe("create and edit panels sharing one form", () => {
72+
let queryClient: QueryClient;
73+
74+
beforeEach(() => {
75+
queryClient = new QueryClient({
76+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
77+
});
78+
});
79+
80+
afterEach(() => {
81+
queryClient.clear();
82+
vi.clearAllMocks();
83+
});
84+
85+
// The reported defect: open an edit panel, then open the create panel, and the create
86+
// form is pre-filled with the record that was just edited. reset(row) in the edit panel
87+
// REPLACES the form's defaultValues, so the create panel's bare reset() restored the
88+
// record instead of a blank form.
89+
it("opens the create panel blank after the edit panel loaded a record", async () => {
90+
let form!: UseFormReturn<ChargeForm>;
91+
const capture = (f: UseFormReturn<ChargeForm>) => {
92+
form = f;
93+
};
94+
95+
const view = render(<SharedFormPanels mode="edit" onForm={capture} />, {
96+
wrapper: wrapper(queryClient),
97+
});
98+
99+
await waitFor(() => expect(form.getValues().code).toBe("DET"));
100+
101+
view.rerender(<SharedFormPanels mode="create" onForm={capture} />);
102+
103+
await waitFor(() => expect(form.getValues()).toEqual(PRISTINE));
104+
});
105+
106+
it("still loads the record into the edit panel", async () => {
107+
let form!: UseFormReturn<ChargeForm>;
108+
109+
render(
110+
<SharedFormPanels
111+
mode="edit"
112+
onForm={(f) => {
113+
form = f;
114+
}}
115+
/>,
116+
{ wrapper: wrapper(queryClient) },
117+
);
118+
119+
// reset(row) puts the whole record in, id included, so match rather than equal.
120+
await waitFor(() =>
121+
expect(form.getValues()).toMatchObject({
122+
code: "DET",
123+
description: "Detention",
124+
status: "Inactive",
125+
}),
126+
);
127+
});
128+
129+
// isDirty in the edit panel must keep meaning "changed since this record loaded", which
130+
// is why the edit panel goes on replacing defaultValues rather than preserving them.
131+
it("keeps the edit panel undirty immediately after loading a record", async () => {
132+
let form!: UseFormReturn<ChargeForm>;
133+
134+
render(
135+
<SharedFormPanels
136+
mode="edit"
137+
onForm={(f) => {
138+
form = f;
139+
}}
140+
/>,
141+
{ wrapper: wrapper(queryClient) },
142+
);
143+
144+
await waitFor(() => expect(form.getValues().code).toBe("DET"));
145+
expect(form.formState.isDirty).toBe(false);
146+
});
147+
148+
it("opens the create panel blank when no edit panel ran first", async () => {
149+
let form!: UseFormReturn<ChargeForm>;
150+
151+
render(
152+
<SharedFormPanels
153+
mode="create"
154+
onForm={(f) => {
155+
form = f;
156+
}}
157+
/>,
158+
{ wrapper: wrapper(queryClient) },
159+
);
160+
161+
await waitFor(() => expect(form.getValues()).toEqual(PRISTINE));
162+
});
163+
});

client/apps/web/src/components/form-create-panel.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Form } from "@trenova/shared/components/ui/form";
33
import { SplitButton, type SplitButtonOption } from "@trenova/shared/components/ui/split-button";
44
import { usePopoutWindow } from "@/hooks/popout-window/use-popout-window";
55
import { useApiMutation } from "@/hooks/use-api-mutation";
6+
import { rememberPristineDefaults } from "@/lib/form-defaults";
67
import {
78
useCreatePanelActionPreference,
89
type CreatePanelSaveAction,
@@ -75,15 +76,21 @@ export function FormCreatePanel<
7576
reset,
7677
} = form;
7778

79+
// Reset to the route's own defaults rather than whatever the form currently calls its
80+
// defaults. The edit panel shares this form and replaces defaultValues with the record it
81+
// loads, so a bare reset() here would open the create panel pre-filled with the last
82+
// record edited.
83+
const pristineDefaults = rememberPristineDefaults<TFieldValues>(form);
84+
7885
useEffect(() => {
7986
if (open) {
80-
reset();
87+
reset(pristineDefaults);
8188
}
82-
}, [open, reset]);
89+
}, [open, reset, pristineDefaults]);
8390

8491
const handleClose = () => {
8592
onOpenChange(false);
86-
reset();
93+
reset(pristineDefaults);
8794
};
8895

8996
const { mutateAsync } = useApiMutation<TMutationData, CreateSubmitPayload, unknown, TFieldValues>(
@@ -113,9 +120,9 @@ export function FormCreatePanel<
113120
const action = variables.action;
114121
if (action === "save-close") {
115122
onOpenChange(false);
116-
reset();
123+
reset(pristineDefaults);
117124
} else if (action === "save-add-another") {
118-
reset();
125+
reset(pristineDefaults);
119126
}
120127
},
121128
form,
@@ -138,7 +145,7 @@ export function FormCreatePanel<
138145

139146
const handlePanelOpenChange = (nextOpen: boolean) => {
140147
if (nextOpen) {
141-
reset();
148+
reset(pristineDefaults);
142149
}
143150
onOpenChange(nextOpen);
144151
};

client/apps/web/src/components/form-edit-panel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Form } from "@trenova/shared/components/ui/form";
33
import { SplitButton, type SplitButtonOption } from "@trenova/shared/components/ui/split-button";
44
import { usePopoutWindow } from "@/hooks/popout-window/use-popout-window";
55
import { useApiMutation } from "@/hooks/use-api-mutation";
6+
import { rememberPristineDefaults } from "@/lib/form-defaults";
67
import {
78
useEditPanelActionPreference,
89
type EditPanelSaveAction,
@@ -81,6 +82,10 @@ export function FormEditPanel<
8182
reset,
8283
} = form;
8384

85+
// Registers the route's defaults before the reset below overwrites them, so the create
86+
// panel sharing this form has a blank state to return to.
87+
rememberPristineDefaults<TFieldValues>(form);
88+
8489
const handleClose = () => {
8590
onOpenChange(false);
8691
reset();
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { DefaultValues, FieldValues } from "react-hook-form";
2+
3+
type FormWithDefaults = {
4+
control: object;
5+
formState: { defaultValues?: unknown };
6+
};
7+
8+
const pristineByControl = new WeakMap<object, unknown>();
9+
10+
// rememberPristineDefaults records the defaults a route gave its form, once, and hands them
11+
// back on every later call.
12+
//
13+
// A route creates a single useForm and passes it to whichever of FormCreatePanel or
14+
// FormEditPanel the current mode selects, so the two panels share one form instance.
15+
// FormEditPanel loads a record with reset(row), and react-hook-form's reset REPLACES
16+
// defaultValues with what it is given — which is what makes isDirty mean "changed since
17+
// this record loaded" while editing. The cost is that a later bare reset() no longer
18+
// restores a blank form; it restores the record that was last edited.
19+
//
20+
// Recording happens during render, before either panel's reset effect has run, so whichever
21+
// panel mounts first captures the route's own defaults rather than a loaded record. Keyed on
22+
// control because that object is stable for the life of the form and lets the entry be
23+
// collected with it.
24+
export function rememberPristineDefaults<TFieldValues extends FieldValues>(
25+
form: FormWithDefaults,
26+
): DefaultValues<TFieldValues> | undefined {
27+
if (!pristineByControl.has(form.control)) {
28+
pristineByControl.set(form.control, form.formState.defaultValues);
29+
}
30+
31+
return pristineByControl.get(form.control) as DefaultValues<TFieldValues> | undefined;
32+
}

0 commit comments

Comments
 (0)