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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof deferred>[] = [];
|
||||
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<typeof deferred>[] = [];
|
||||
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<typeof deferred>[] = [];
|
||||
|
||||
@@ -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<PreviewState>({ 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 (
|
||||
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
|
||||
|
||||
Reference in New Issue
Block a user