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>
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
"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 };
|
|
}
|