pelagia-portal/App/components/po/use-unsaved-changes.ts
Claude (auto-fix) 61345dd7b7 feat(po): prompt to save as draft when leaving with unsaved changes
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>
2026-06-19 05:17:32 +05:30

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