From 8d74efd495333dd75f01453b59bad95477387249 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 22:25:23 -0400 Subject: [PATCH] Hide a previous rule's preview while the edited rule is fetched Tag each preview result with the rule it answers and render it only while that rule is still the draft's, so a rule edit clears the old dates or error immediately. Cover window and assignee edits not requesting, and the 300 ms debounce boundary. Co-Authored-By: Claude Opus 5.5 --- admin/src/definitions/DefinitionsTab.test.tsx | 91 +++++++++++++++++++ admin/src/definitions/DefinitionsTab.tsx | 16 +++- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 9f2c7ea..238f769 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -749,6 +749,97 @@ describe("DefinitionsTab occurrence preview", () => { expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1); }); + it("window and assignee edits never request, and a rule edit waits out the debounce", async () => { + await renderReady(); + api.setPreview(async () => jsonResponse({ dates: BINS_DATES })); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + expect(api.previews()).toHaveLength(1); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + // Windows and assignees are not part of the rule. + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" })); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(api.previews()).toHaveLength(1); + expect(previewText()).toContain("Mon 5 Oct"); + + // A rule edit is not requested until the 300 ms debounce elapses. + const days = within(s).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Tuesday" })); + await act(async () => { + vi.advanceTimersByTime(299); + }); + expect(api.previews()).toHaveLength(1); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(api.previews()).toHaveLength(2); + expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([ + 0, 1, 3, + ]); + }); + + it("a rule edit hides the previous rule's dates until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ dates: BINS_DATES })); + }); + await waitFor(() => expect(previewText()).toContain("Mon 5 Oct")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + // Within the debounce: the old dates are already gone. + expect(previewText()).toBeNull(); + // Request in flight: still nothing shown for the new rule. + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct")); + }); + + it("a rule edit hides the previous rule's error until its own response arrives", async () => { + await renderReady(); + const pending: ReturnType[] = []; + api.setPreview(() => { + const next = deferred(); + pending.push(next); + return next.promise; + }); + fireEvent.click(screen.getByTestId("definition-11")); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422)); + }); + await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected")); + + const days = within(sheet()).getByRole("group", { name: "Days" }); + fireEvent.click(within(days).getByRole("button", { name: "Monday" })); + expect(previewText()).toBeNull(); + await waitFor(() => expect(pending).toHaveLength(2)); + expect(previewText()).toBeNull(); + + await act(async () => { + pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] })); + }); + await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct")); + }); + it("a stale response resolving after a newer one does not overwrite it", async () => { await renderReady(); const pending: ReturnType[] = []; diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index fed5c4b..05cc19a 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -337,14 +337,16 @@ function DefinitionRow({ type PreviewState = | { kind: "idle" } - | { kind: "ready"; dates: string[] } - | { kind: "error"; message: string }; + | { kind: "ready"; ruleKey: string; dates: string[] } + | { kind: "error"; ruleKey: string; message: string }; /** * The next few dates the backend would materialize for the draft's rule. * `ruleKey` is the built rule serialized (null while the draft is invalid), so * the request only goes out when the rule itself changes — debounced, and a * superseded request's late response is dropped by its effect's cleanup. + * A result is tagged with the rule it answers and only shown while that rule + * is still the draft's, so a changed rule never shows the previous dates. */ function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { const [preview, setPreview] = useState({ kind: "idle" }); @@ -358,7 +360,7 @@ function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { const timer = setTimeout(() => { previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT }) .then((dates) => { - if (!cancelled) setPreview({ kind: "ready", dates }); + if (!cancelled) setPreview({ kind: "ready", ruleKey, dates }); }) .catch((error: unknown) => { if (cancelled) return; @@ -367,7 +369,11 @@ function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { (error instanceof ApiRequestError && error.detail) ? `: ${error.message}` : "."; - setPreview({ kind: "error", message: `Upcoming dates unavailable${detail}` }); + setPreview({ + kind: "error", + ruleKey, + message: `Upcoming dates unavailable${detail}`, + }); }); }, PREVIEW_DEBOUNCE_MS); return () => { @@ -376,7 +382,7 @@ function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) { }; }, [ruleKey]); - if (ruleKey === null || preview.kind === "idle") return null; + if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null; if (preview.kind === "error") { return (