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:
2026-06-07 23:26:15 +02:00
parent 537b847acb
commit e18cad9c6a
6 changed files with 154 additions and 3 deletions
+42
View File
@@ -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>
);
}
+85
View File
@@ -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);
});
+18
View File
@@ -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;
}