Both the PO create and edit screens now guard against accidental loss of in-progress data. While a form has unsaved changes: - in-app link clicks and the edit screen's Cancel button are intercepted, showing a dialog offering Save as Draft / Discard changes / Stay on page - hard unloads (reload, tab close, external navigation) trigger the browser's native warning via beforeunload "Save as Draft" reuses the existing draft-save action; validation errors are surfaced in the dialog so the user can recover or discard. Dirty state is tracked from any field edit, line-item change, or attachment change. Adds useUnsavedChanges hook + UnsavedChangesDialog component and unit tests. Fixes #18 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
5.9 KiB
TypeScript
147 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { UnsavedChangesDialog } from "@/components/po/unsaved-changes-dialog";
|
|
import { useUnsavedChanges } from "@/components/po/use-unsaved-changes";
|
|
|
|
const push = vi.fn();
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push, back: vi.fn() }),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
push.mockReset();
|
|
});
|
|
|
|
// ── Dialog ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("UnsavedChangesDialog", () => {
|
|
const noop = async () => ({ ok: true });
|
|
|
|
it("renders the three choices when open", () => {
|
|
render(
|
|
<UnsavedChangesDialog open onSaveDraft={noop} onDiscard={vi.fn()} onStay={vi.fn()} />
|
|
);
|
|
expect(screen.getByRole("button", { name: /save as draft/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /discard changes/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /stay on page/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders nothing when closed", () => {
|
|
const { container } = render(
|
|
<UnsavedChangesDialog open={false} onSaveDraft={noop} onDiscard={vi.fn()} onStay={vi.fn()} />
|
|
);
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it("invokes onDiscard / onStay when those buttons are clicked", () => {
|
|
const onDiscard = vi.fn();
|
|
const onStay = vi.fn();
|
|
render(
|
|
<UnsavedChangesDialog open onSaveDraft={noop} onDiscard={onDiscard} onStay={onStay} />
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: /discard changes/i }));
|
|
expect(onDiscard).toHaveBeenCalledOnce();
|
|
fireEvent.click(screen.getByRole("button", { name: /stay on page/i }));
|
|
expect(onStay).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("shows an error and keeps the prompt open when saving the draft fails", async () => {
|
|
const onSaveDraft = vi.fn(async () => ({ ok: false, error: "Title is required" }));
|
|
const onStay = vi.fn();
|
|
render(
|
|
<UnsavedChangesDialog open onSaveDraft={onSaveDraft} onDiscard={vi.fn()} onStay={onStay} />
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: /save as draft/i }));
|
|
expect(onSaveDraft).toHaveBeenCalledOnce();
|
|
await waitFor(() => expect(screen.getByText("Title is required")).toBeInTheDocument());
|
|
// still open — user can recover
|
|
expect(onStay).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("button", { name: /stay on page/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("uses a custom save label when provided", () => {
|
|
render(
|
|
<UnsavedChangesDialog open onSaveDraft={noop} onDiscard={vi.fn()} onStay={vi.fn()} saveLabel="Save Draft" />
|
|
);
|
|
expect(screen.getByRole("button", { name: "Save Draft" })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Hook ────────────────────────────────────────────────────────────────────
|
|
|
|
function Harness({ dirty }: { dirty: boolean }) {
|
|
const { promptOpen, guard, leave, stay } = useUnsavedChanges(dirty);
|
|
return (
|
|
<div>
|
|
<span data-testid="open">{String(promptOpen)}</span>
|
|
<a href="/dashboard">internal link</a>
|
|
<a href="/po/new#section">same page anchor</a>
|
|
<a href="https://example.com/x">external link</a>
|
|
<button onClick={() => guard(() => push("/programmatic"))}>cancel</button>
|
|
<button onClick={leave}>leave</button>
|
|
<button onClick={stay}>stay</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("useUnsavedChanges", () => {
|
|
it("intercepts internal link clicks while dirty", () => {
|
|
render(<Harness dirty />);
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
fireEvent.click(screen.getByText("internal link"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("true");
|
|
expect(push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not intercept link clicks when clean", () => {
|
|
render(<Harness dirty={false} />);
|
|
fireEvent.click(screen.getByText("internal link"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
});
|
|
|
|
it("ignores external links", () => {
|
|
render(<Harness dirty />);
|
|
fireEvent.click(screen.getByText("external link"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
});
|
|
|
|
it("intercepts guarded programmatic navigation while dirty, then proceeds on leave", () => {
|
|
render(<Harness dirty />);
|
|
fireEvent.click(screen.getByText("cancel"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("true");
|
|
expect(push).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByText("leave"));
|
|
expect(push).toHaveBeenCalledWith("/programmatic");
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
});
|
|
|
|
it("runs programmatic navigation immediately when clean", () => {
|
|
render(<Harness dirty={false} />);
|
|
fireEvent.click(screen.getByText("cancel"));
|
|
expect(push).toHaveBeenCalledWith("/programmatic");
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
});
|
|
|
|
it("closes the prompt without navigating on stay", () => {
|
|
render(<Harness dirty />);
|
|
fireEvent.click(screen.getByText("cancel"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("true");
|
|
fireEvent.click(screen.getByText("stay"));
|
|
expect(screen.getByTestId("open")).toHaveTextContent("false");
|
|
expect(push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns via beforeunload while dirty", () => {
|
|
render(<Harness dirty />);
|
|
const evt = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(evt);
|
|
expect(evt.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("does not warn via beforeunload when clean", () => {
|
|
render(<Harness dirty={false} />);
|
|
const evt = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(evt);
|
|
expect(evt.defaultPrevented).toBe(false);
|
|
});
|
|
});
|