Closes #18. Navigating away from a PO create/edit screen with unsaved changes could silently lose in-progress work. The forms now track a dirty flag and guard both navigation paths: - Hard navigations (refresh / tab close / external link) → the browser's native "Leave site?" prompt via beforeunload. - In-app navigations (sidebar / header / any internal link) → a capture-phase click interceptor opens a modal offering Save as draft / Discard changes / Stay on page. Save as draft runs the form's existing draft save (which redirects to the PO); Discard continues to the intended destination. The guard (components/po/unsaved-changes-guard.tsx) is reusable and wired into both new-po-form and edit-po-form. dirty is cleared before a successful submit so saving never trips the prompt. SPA back-button (popstate) is left to beforeunload only; the manager inline-edit panel is out of scope (saves in place, no draft concept). Tests: 7 new unit cases for the guard (intercept-when-dirty, no-op-when-clean, external links pass through, Stay/Discard/Save actions, beforeunload arming). Unit suite 296 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
4.2 KiB
TypeScript
94 lines
4.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
|
|
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
|
|
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
|
|
|
// Each test adds a real <a> to the document so the capture-phase click
|
|
// interceptor has something to catch.
|
|
const links: HTMLAnchorElement[] = [];
|
|
function addLink(href: string) {
|
|
const a = document.createElement("a");
|
|
a.setAttribute("href", href);
|
|
a.textContent = "go";
|
|
document.body.appendChild(a);
|
|
links.push(a);
|
|
return a;
|
|
}
|
|
|
|
beforeEach(() => pushMock.mockClear());
|
|
afterEach(() => {
|
|
cleanup();
|
|
links.splice(0).forEach((a) => a.remove());
|
|
});
|
|
|
|
const noop = () => {};
|
|
|
|
describe("UnsavedChangesGuard", () => {
|
|
it("does not intercept navigation when there are no unsaved changes", () => {
|
|
render(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
|
const link = addLink("/catalogue/vendors");
|
|
const notCanceled = fireEvent.click(link);
|
|
expect(notCanceled).toBe(true); // default not prevented → navigation proceeds
|
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("intercepts an internal link click and opens the prompt when dirty", () => {
|
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
|
const link = addLink("/catalogue/vendors");
|
|
const notCanceled = fireEvent.click(link);
|
|
expect(notCanceled).toBe(false); // navigation blocked
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Save as draft" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Discard changes" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Stay on page" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not intercept external links (left to the browser's own prompt)", () => {
|
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
|
const link = addLink("https://example.com/elsewhere");
|
|
const notCanceled = fireEvent.click(link);
|
|
expect(notCanceled).toBe(true);
|
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("'Stay on page' closes the prompt without navigating", () => {
|
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
|
fireEvent.click(addLink("/dashboard"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Stay on page" }));
|
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
|
expect(pushMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("'Discard changes' navigates to the intended destination", () => {
|
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
|
fireEvent.click(addLink("/catalogue/vendors"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
|
expect(pushMock).toHaveBeenCalledWith("/catalogue/vendors");
|
|
});
|
|
|
|
it("'Save as draft' runs the save handler and closes the prompt", () => {
|
|
const onSaveDraft = vi.fn();
|
|
render(<UnsavedChangesGuard enabled onSaveDraft={onSaveDraft} saving={false} />);
|
|
fireEvent.click(addLink("/dashboard"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Save as draft" }));
|
|
expect(onSaveDraft).toHaveBeenCalledTimes(1);
|
|
expect(pushMock).not.toHaveBeenCalled(); // the save action does its own redirect
|
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("arms the browser beforeunload prompt only while dirty", () => {
|
|
const { rerender } = render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
|
const dirtyEvt = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(dirtyEvt);
|
|
expect(dirtyEvt.defaultPrevented).toBe(true);
|
|
|
|
rerender(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
|
const cleanEvt = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(cleanEvt);
|
|
expect(cleanEvt.defaultPrevented).toBe(false);
|
|
});
|
|
});
|