Warn on tab close when create-flow module or wizard edits have not been saved.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-08-31 11:00:40 -06:00
co-authored by Cursor
parent a820739d07
commit 7f67cc6271
19 changed files with 350 additions and 1 deletions
@@ -6,6 +6,7 @@ import {
cleanup,
within,
waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
@@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect(labels[1]).toMatch(/Code of Conduct/);
expect(labels[2]).toMatch(/Core Principle/);
});
it("does not block tab close when the create modal is unchanged", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks tab close while the create modal has unsaved field edits", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } });
expect(dispatchBeforeUnload()).toBe(true);
});
it("blocks tab close while the custom-policy wizard has unsaved edits", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
const nameInput = await screen.findByPlaceholderText("Policy name");
expect(dispatchBeforeUnload()).toBe(false);
fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } });
expect(dispatchBeforeUnload()).toBe(true);
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils";
import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen";
describe("CoreValuesSelectScreen", () => {
@@ -65,6 +65,20 @@ describe("CoreValuesSelectScreen", () => {
});
});
it("does not block tab close when a pending value is unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks tab close after editing a pending value", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning");
expect(dispatchBeforeUnload()).toBe(true);
});
it("keeps the pending value modal open when Keep editing is chosen", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import {
renderWithProviders as render,
screen,
fireEvent,
dispatchBeforeUnload,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import CustomMethodCardWizard from "../../app/(app)/create/components/CustomMethodCardWizard";
describe("CustomMethodCardWizard — tab close guard", () => {
it("does not block unload when the wizard is open but unchanged", async () => {
render(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
await screen.findByPlaceholderText("Policy name");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks unload after the user types a policy name", async () => {
render(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
const name = await screen.findByPlaceholderText("Policy name");
fireEvent.change(name, { target: { value: "Garden hours" } });
expect(dispatchBeforeUnload()).toBe(true);
});
});
+23
View File
@@ -5,6 +5,7 @@ import {
renderWithProviders as render,
screen,
waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
@@ -623,6 +624,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
expect(latest.communicationMethodDetailsById).toBeUndefined();
});
it("blocks tab close while chip edits are unsaved", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {
/* noop */
}}
initial={baseSelections}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
const principleField = within(dialog).getByRole("textbox", {
name: /core principle/i,
});
fireEvent.change(principleField, {
target: { value: "Unsaved on tab close" },
});
expect(dispatchBeforeUnload()).toBe(true);
});
it("shows consolidated placeholder for user-authored communication chips", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
render(
@@ -4,6 +4,7 @@ import {
captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
isMethodCardCustomizeUnloadBlocked,
} from "../../lib/create/methodCardCustomizeSession";
const HEADER_0 = { title: "", description: "" };
@@ -88,4 +89,24 @@ describe("methodCardCustomizeSession", () => {
).toBe(false);
expect(confirmFn).toHaveBeenCalled();
});
it("unload block is false when the modal is closed or snapshot is missing", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0),
).toBe(false);
});
it("unload block is true only when the open modal is dirty", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0),
).toBe(true);
});
});
@@ -0,0 +1,26 @@
import { renderHook } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useBeforeUnloadGuard } from "../../../app/hooks/useBeforeUnloadGuard";
import { dispatchBeforeUnload } from "../../utils/test-utils";
describe("useBeforeUnloadGuard", () => {
it("does not intercept unload when disabled", () => {
renderHook(() => useBeforeUnloadGuard(false));
expect(dispatchBeforeUnload()).toBe(false);
});
it("prevents unload when enabled", () => {
renderHook(() => useBeforeUnloadGuard(true));
expect(dispatchBeforeUnload()).toBe(true);
});
it("drops the listener when enabled flips to false", () => {
const { rerender } = renderHook(
({ enabled }) => useBeforeUnloadGuard(enabled),
{ initialProps: { enabled: true } },
);
expect(dispatchBeforeUnload()).toBe(true);
rerender({ enabled: false });
expect(dispatchBeforeUnload()).toBe(false);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
} from "../../lib/create/methodCardCustomizeSession";
const HEADER_0 = { title: "", description: "" };
describe("isMethodCardCustomizeUnloadBlocked", () => {
it("is false when the modal is closed or snapshot is missing", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0),
).toBe(false);
});
it("is true only when the open modal is dirty", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0),
).toBe(true);
});
});
+7
View File
@@ -25,5 +25,12 @@ export function renderWithProviders(
return render(ui, { wrapper: Wrapper, ...options });
}
/** True when a `beforeunload` listener called `preventDefault` (tab-close guard). */
export function dispatchBeforeUnload(): boolean {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
// Re-export everything from @testing-library/react for convenience
export * from "@testing-library/react";