feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Blocker } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
|
||||
const { t } = useTranslation();
|
||||
const open = blocker.state === "blocked";
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) blocker.reset?.();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => blocker.reset?.()}>
|
||||
{t("form.unsaved.stay")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => blocker.proceed?.()}>
|
||||
{t("form.unsaved.leave")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter, Link, RouterProvider } from "react-router-dom";
|
||||
|
||||
import { UnsavedChangesDialog } from "./unsaved-changes-dialog";
|
||||
import { useUnsavedChanges } from "./use-unsaved-changes";
|
||||
import "../i18n";
|
||||
|
||||
function Editor({ active }: { active: boolean }) {
|
||||
const blocker = useUnsavedChanges(active);
|
||||
return (
|
||||
<div>
|
||||
<h1>Editor</h1>
|
||||
<Link to="/other">go elsewhere</Link>
|
||||
<UnsavedChangesDialog blocker={blocker} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGuard(active: boolean) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: "/", element: <Editor active={active} /> },
|
||||
{ path: "/other", element: <h1>Other page</h1> },
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("dirty nav shows the dialog and Keep editing stays", async () => {
|
||||
renderGuard(true);
|
||||
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||
|
||||
expect(await screen.findByText("Discard unsaved changes?")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Editor" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Other page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("dirty nav with Discard proceeds to the target route", async () => {
|
||||
renderGuard(true);
|
||||
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Discard" }));
|
||||
|
||||
expect(await screen.findByText("Other page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clean form navigates without the dialog", async () => {
|
||||
renderGuard(false);
|
||||
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||
|
||||
expect(await screen.findByText("Other page")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Discard unsaved changes?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("registers a beforeunload listener only when active", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
const { unmount } = renderGuard(true);
|
||||
|
||||
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not register beforeunload when inactive", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
|
||||
renderGuard(false);
|
||||
|
||||
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useBlocker } from "react-router-dom";
|
||||
|
||||
export function useUnsavedChanges(active: boolean) {
|
||||
const blocker = useBlocker(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [active]);
|
||||
|
||||
return blocker;
|
||||
}
|
||||
Reference in New Issue
Block a user