Merge pull request 'fix: Prompt to save PO as draft when navigating away with unsaved changes' (#30) from claude/issue-18 into master
Reviewed-on: #30
This commit is contained in:
commit
405e3bfb5b
5 changed files with 377 additions and 12 deletions
|
|
@ -6,6 +6,8 @@ import { updatePo } from "./actions";
|
|||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { UnsavedChangesDialog } from "@/components/po/unsaved-changes-dialog";
|
||||
import { useUnsavedChanges } from "@/components/po/use-unsaved-changes";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||
|
|
@ -62,11 +64,13 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const { promptOpen, guard, leave, stay } = useUnsavedChanges(dirty);
|
||||
|
||||
const canSubmit = po.status === "DRAFT";
|
||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||
|
||||
async function handleSubmit(intent: "save" | "submit" | "resubmit") {
|
||||
async function handleSubmit(intent: "save" | "submit" | "resubmit"): Promise<{ ok: boolean; error?: string }> {
|
||||
setSubmitting(intent);
|
||||
setError("");
|
||||
const form = document.getElementById("edit-po-form") as HTMLFormElement;
|
||||
|
|
@ -87,9 +91,11 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setSubmitting(null);
|
||||
} else {
|
||||
router.push(`/po/${result.id}`);
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
setDirty(false);
|
||||
router.push(`/po/${result.id}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const poDateValue = po.poDate
|
||||
|
|
@ -108,7 +114,13 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
const extPo = po;
|
||||
|
||||
return (
|
||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||
<form
|
||||
id="edit-po-form"
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onChange={() => setDirty(true)}
|
||||
onInput={() => setDirty(true)}
|
||||
>
|
||||
{canResubmit && (
|
||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-warning-700">
|
||||
|
|
@ -238,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={setLineItems}
|
||||
onChange={(items) => { setLineItems(items); setDirty(true); }}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
|
|
@ -303,7 +315,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => router.back()}
|
||||
<button type="button" onClick={() => guard(() => router.back())}
|
||||
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -324,6 +336,14 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
open={promptOpen}
|
||||
onSaveDraft={() => handleSubmit("save")}
|
||||
onDiscard={leave}
|
||||
onStay={stay}
|
||||
saveLabel="Save Draft"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { createPo } from "./actions";
|
|||
import type { Vendor } from "@prisma/client";
|
||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { FileUploader } from "@/components/po/file-uploader";
|
||||
import { UnsavedChangesDialog } from "@/components/po/unsaved-changes-dialog";
|
||||
import { useUnsavedChanges } from "@/components/po/use-unsaved-changes";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
|
|
@ -42,8 +44,10 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
const [error, setError] = useState("");
|
||||
const [multiAccount, setMultiAccount] = useState(false);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const { promptOpen, leave, stay } = useUnsavedChanges(dirty);
|
||||
|
||||
async function handleSubmit(intent: "draft" | "submit") {
|
||||
async function handleSubmit(intent: "draft" | "submit"): Promise<{ ok: boolean; error?: string }> {
|
||||
setSubmitting(intent);
|
||||
setError("");
|
||||
const form = document.getElementById("po-form") as HTMLFormElement;
|
||||
|
|
@ -65,21 +69,29 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setSubmitting(null);
|
||||
return;
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
if (files.length > 0) {
|
||||
const uploadErr = await uploadAndLinkFiles(result.id, files);
|
||||
if (uploadErr) {
|
||||
setError(uploadErr.error);
|
||||
setSubmitting(null);
|
||||
return;
|
||||
return { ok: false, error: uploadErr.error };
|
||||
}
|
||||
}
|
||||
setDirty(false);
|
||||
router.push(`/po/${result.id}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||
<form
|
||||
id="po-form"
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onChange={() => setDirty(true)}
|
||||
onInput={() => setDirty(true)}
|
||||
>
|
||||
{/* Order Information */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||
|
|
@ -208,7 +220,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={setLineItems}
|
||||
onChange={(items) => { setLineItems(items); setDirty(true); }}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
|
|
@ -272,7 +284,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
{/* Attachments */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
|
||||
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
|
||||
<FileUploader files={files} onChange={(f) => { setFiles(f); setDirty(true); }} disabled={!!submitting} />
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
|
|
@ -297,6 +309,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
open={promptOpen}
|
||||
onSaveDraft={() => handleSubmit("draft")}
|
||||
onDiscard={leave}
|
||||
onStay={stay}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
82
App/components/po/unsaved-changes-dialog.tsx
Normal file
82
App/components/po/unsaved-changes-dialog.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Saves the PO as a draft. Resolves `{ ok: true }` on success. */
|
||||
onSaveDraft: () => Promise<{ ok: boolean; error?: string }>;
|
||||
/** Leave the page without saving. */
|
||||
onDiscard: () => void;
|
||||
/** Cancel and stay on the page. */
|
||||
onStay: () => void;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
open,
|
||||
onSaveDraft,
|
||||
onDiscard,
|
||||
onStay,
|
||||
saveLabel = "Save as Draft",
|
||||
}: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const result = await onSaveDraft();
|
||||
setSaving(false);
|
||||
// On success the form navigates away; on failure keep the prompt open so
|
||||
// the user can fix the problem (or discard).
|
||||
if (!result.ok) setError(result.error ?? "Could not save draft.");
|
||||
}
|
||||
|
||||
function handleStay() {
|
||||
if (saving) return;
|
||||
setError("");
|
||||
onStay();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminDialog title="Unsaved changes" open={open} onClose={handleStay}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-neutral-600">
|
||||
You have unsaved changes to this purchase order. Would you like to
|
||||
save it as a draft before leaving?
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStay}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||
>
|
||||
Stay on page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!saving) onDiscard(); }}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-danger-200 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60"
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
97
App/components/po/use-unsaved-changes.ts
Normal file
97
App/components/po/use-unsaved-changes.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Guards a form against accidental loss of in-progress data.
|
||||
*
|
||||
* While `isDirty` is true it:
|
||||
* - shows the browser's native warning on hard unloads (reload / tab close /
|
||||
* external navigation), and
|
||||
* - intercepts in-app link clicks so the caller can show a custom prompt
|
||||
* offering to save the work as a draft before leaving.
|
||||
*
|
||||
* Programmatic navigations (e.g. a "Cancel" button calling `router.back()`)
|
||||
* should be wrapped with the returned `guard()` so they are intercepted too.
|
||||
*/
|
||||
export function useUnsavedChanges(isDirty: boolean) {
|
||||
const router = useRouter();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
dirtyRef.current = isDirty;
|
||||
// Set once the user has chosen to leave, so the pending navigation isn't
|
||||
// re-intercepted on the way out.
|
||||
const bypassRef = useRef(false);
|
||||
const [pendingNav, setPendingNav] = useState<null | (() => void)>(null);
|
||||
const pendingRef = useRef<null | (() => void)>(null);
|
||||
pendingRef.current = pendingNav;
|
||||
|
||||
// Native warning for reload / tab-close / external links.
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [isDirty]);
|
||||
|
||||
// Intercept clicks on in-app links so we can prompt before leaving.
|
||||
useEffect(() => {
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!dirtyRef.current || bypassRef.current) return;
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
|
||||
const anchor = (e.target as HTMLElement | null)?.closest?.("a");
|
||||
if (!anchor) return;
|
||||
if (anchor.hasAttribute("download")) return;
|
||||
const target = anchor.getAttribute("target");
|
||||
if (target && target !== "_self") return;
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href) return;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(anchor.href, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return;
|
||||
|
||||
const dest = url.pathname + url.search + url.hash;
|
||||
const here = window.location.pathname + window.location.search;
|
||||
// Same page (e.g. an in-page #anchor) — let it through.
|
||||
if (url.pathname + url.search === here) return;
|
||||
|
||||
e.preventDefault();
|
||||
setPendingNav(() => () => router.push(dest));
|
||||
}
|
||||
|
||||
document.addEventListener("click", onClick, true);
|
||||
return () => document.removeEventListener("click", onClick, true);
|
||||
}, [router]);
|
||||
|
||||
/** Wrap a programmatic navigation so it is intercepted while dirty. */
|
||||
const guard = useCallback((run: () => void) => {
|
||||
if (!dirtyRef.current || bypassRef.current) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
setPendingNav(() => run);
|
||||
}, []);
|
||||
|
||||
/** Proceed with the pending navigation, discarding changes. */
|
||||
const leave = useCallback(() => {
|
||||
bypassRef.current = true;
|
||||
const run = pendingRef.current;
|
||||
setPendingNav(null);
|
||||
run?.();
|
||||
}, []);
|
||||
|
||||
/** Cancel the prompt and stay on the page. */
|
||||
const stay = useCallback(() => setPendingNav(null), []);
|
||||
|
||||
return { promptOpen: pendingNav !== null, guard, leave, stay };
|
||||
}
|
||||
147
App/tests/unit/unsaved-changes.test.tsx
Normal file
147
App/tests/unit/unsaved-changes.test.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue