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>
121 lines
4.8 KiB
TypeScript
121 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
|
|
interface Props {
|
|
/** Arm the guard — true once the form has unsaved changes. */
|
|
enabled: boolean;
|
|
/** Persist the in-progress PO as a draft. Should navigate away on success. */
|
|
onSaveDraft: () => void;
|
|
/** True while the draft save is in flight (drives the button label/disable). */
|
|
saving: boolean;
|
|
}
|
|
|
|
// Warns the user before they leave a PO form with unsaved changes (issue #18).
|
|
// Two paths are covered:
|
|
// • Hard navigations (refresh, tab close, external links) → the browser's own
|
|
// "Leave site?" prompt (browsers can't render custom buttons here, so the
|
|
// save-as-draft option isn't offered on this path).
|
|
// • In-app navigations (sidebar / header / any internal <a>) → intercepted and
|
|
// replaced with our own modal offering Save as draft / Discard / Stay.
|
|
export function UnsavedChangesGuard({ enabled, onSaveDraft, saving }: Props) {
|
|
const router = useRouter();
|
|
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
|
// Listeners are attached once; read `enabled` through a ref so they always see
|
|
// the latest value without re-binding on every keystroke.
|
|
const enabledRef = useRef(enabled);
|
|
enabledRef.current = enabled;
|
|
|
|
useEffect(() => {
|
|
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
if (!enabledRef.current) return;
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
}
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function onClick(e: MouseEvent) {
|
|
if (!enabledRef.current || e.defaultPrevented) return;
|
|
// Ignore non-primary clicks and modifier-clicks (new tab / download etc.).
|
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
const anchor = (e.target as HTMLElement | null)?.closest("a");
|
|
const href = anchor?.getAttribute("href");
|
|
if (!anchor || !href || href.startsWith("#")) return;
|
|
if (anchor.hasAttribute("download")) return;
|
|
if (anchor.target && anchor.target !== "_self") return;
|
|
|
|
const url = new URL(href, window.location.href);
|
|
// External origin → let the browser's beforeunload prompt handle it.
|
|
if (url.origin !== window.location.origin) return;
|
|
// Same page (e.g. a no-op link) → nothing to guard.
|
|
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setPendingHref(url.pathname + url.search + url.hash);
|
|
}
|
|
// Capture phase so we run before Next's <Link> click handler.
|
|
document.addEventListener("click", onClick, true);
|
|
return () => document.removeEventListener("click", onClick, true);
|
|
}, []);
|
|
|
|
const discard = useCallback(() => {
|
|
const href = pendingHref;
|
|
setPendingHref(null);
|
|
enabledRef.current = false; // let this navigation through
|
|
if (href) router.push(href);
|
|
}, [pendingHref, router]);
|
|
|
|
const stay = useCallback(() => {
|
|
if (saving) return;
|
|
setPendingHref(null);
|
|
}, [saving]);
|
|
|
|
function saveDraft() {
|
|
// Close the prompt so any inline save error is visible; the save action
|
|
// navigates to the PO on success.
|
|
setPendingHref(null);
|
|
onSaveDraft();
|
|
}
|
|
|
|
return (
|
|
<AdminDialog title="Unsaved changes" open={pendingHref !== null} onClose={stay}>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-neutral-600">
|
|
You have unsaved changes on this purchase order. Save it as a draft before leaving, or discard your changes?
|
|
</p>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={stay}
|
|
disabled={saving}
|
|
className="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 sm:order-1"
|
|
>
|
|
Stay on page
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={discard}
|
|
disabled={saving}
|
|
className="rounded-lg border border-danger-200 bg-white px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60 sm:order-2"
|
|
>
|
|
Discard changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={saveDraft}
|
|
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 sm:order-3"
|
|
>
|
|
{saving ? "Saving…" : "Save as draft"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</AdminDialog>
|
|
);
|
|
}
|