pelagia-portal/App/tests/unit/unsaved-changes-guard.test.tsx
Hardik 7d4ad6a9b8
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 32s
feat(po): prompt to save as draft when leaving with unsaved changes
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>
2026-06-24 06:37:33 +05:30

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);
});
});