feat(po): prompt to save as draft when leaving with unsaved changes
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>
This commit is contained in:
parent
21df005ab6
commit
7d4ad6a9b8
5 changed files with 252 additions and 9 deletions
|
|
@ -113,6 +113,14 @@ Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a *
|
||||||
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
|
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
|
||||||
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
|
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
|
||||||
|
|
||||||
|
### Unsaved-changes prompt (issue #18)
|
||||||
|
|
||||||
|
The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `<UnsavedChangesGuard>` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and:
|
||||||
|
- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
|
||||||
|
- **In-app navigations** (sidebar / header / any internal `<a>`) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**.
|
||||||
|
|
||||||
|
`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept).
|
||||||
|
|
||||||
### PO Numbering (`lib/po-number.ts`)
|
### PO Numbering (`lib/po-number.ts`)
|
||||||
|
|
||||||
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
|
@ -69,6 +70,8 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const markDirty = () => setDirty(true);
|
||||||
|
|
||||||
const canSubmit = po.status === "DRAFT";
|
const canSubmit = po.status === "DRAFT";
|
||||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||||
|
|
@ -96,6 +99,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setSubmitting(null);
|
setSubmitting(null);
|
||||||
} else {
|
} else {
|
||||||
|
setDirty(false); // saved — don't warn on the redirect
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +120,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const extPo = po;
|
const extPo = po;
|
||||||
|
|
||||||
return (
|
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()} onInput={markDirty} onChange={markDirty}>
|
||||||
{canResubmit && (
|
{canResubmit && (
|
||||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
<p className="text-sm font-medium text-warning-700">
|
<p className="text-sm font-medium text-warning-700">
|
||||||
|
|
@ -180,7 +184,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -246,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -263,7 +267,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||||
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
||||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -292,6 +296,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UnsavedChangesGuard
|
||||||
|
enabled={dirty && !submitting}
|
||||||
|
onSaveDraft={() => handleSubmit("save")}
|
||||||
|
saving={submitting === "save"}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
@ -48,6 +49,8 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
const [multiAccount, setMultiAccount] = useState(false);
|
const [multiAccount, setMultiAccount] = useState(false);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const markDirty = () => setDirty(true);
|
||||||
|
|
||||||
async function handleSubmit(intent: "draft" | "submit") {
|
async function handleSubmit(intent: "draft" | "submit") {
|
||||||
setSubmitting(intent);
|
setSubmitting(intent);
|
||||||
|
|
@ -82,11 +85,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setDirty(false); // saved — don't warn on the redirect
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()} onInput={markDirty} onChange={markDirty}>
|
||||||
{/* Order Information */}
|
{/* Order Information */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||||
|
|
@ -143,7 +147,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -215,7 +219,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -237,13 +241,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||||
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration → Terms & Conditions.</p>
|
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration → Terms & Conditions.</p>
|
||||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<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={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -268,6 +272,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UnsavedChangesGuard
|
||||||
|
enabled={dirty && !submitting}
|
||||||
|
onSaveDraft={() => handleSubmit("draft")}
|
||||||
|
saving={submitting === "draft"}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
App/components/po/unsaved-changes-guard.tsx
Normal file
121
App/components/po/unsaved-changes-guard.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
App/tests/unit/unsaved-changes-guard.test.tsx
Normal file
94
App/tests/unit/unsaved-changes-guard.test.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||||
|
|
||||||
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||||
|
|
||||||
|
// Each test adds a real <a> to the document so the capture-phase click
|
||||||
|
// interceptor has something to catch.
|
||||||
|
const links: HTMLAnchorElement[] = [];
|
||||||
|
function addLink(href: string) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.setAttribute("href", href);
|
||||||
|
a.textContent = "go";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
links.push(a);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => pushMock.mockClear());
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
links.splice(0).forEach((a) => a.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
describe("UnsavedChangesGuard", () => {
|
||||||
|
it("does not intercept navigation when there are no unsaved changes", () => {
|
||||||
|
render(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
||||||
|
const link = addLink("/catalogue/vendors");
|
||||||
|
const notCanceled = fireEvent.click(link);
|
||||||
|
expect(notCanceled).toBe(true); // default not prevented → navigation proceeds
|
||||||
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("intercepts an internal link click and opens the prompt when dirty", () => {
|
||||||
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||||
|
const link = addLink("/catalogue/vendors");
|
||||||
|
const notCanceled = fireEvent.click(link);
|
||||||
|
expect(notCanceled).toBe(false); // navigation blocked
|
||||||
|
expect(pushMock).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Save as draft" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Discard changes" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Stay on page" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept external links (left to the browser's own prompt)", () => {
|
||||||
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||||
|
const link = addLink("https://example.com/elsewhere");
|
||||||
|
const notCanceled = fireEvent.click(link);
|
||||||
|
expect(notCanceled).toBe(true);
|
||||||
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Stay on page' closes the prompt without navigating", () => {
|
||||||
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||||
|
fireEvent.click(addLink("/dashboard"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Stay on page" }));
|
||||||
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||||
|
expect(pushMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Discard changes' navigates to the intended destination", () => {
|
||||||
|
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||||
|
fireEvent.click(addLink("/catalogue/vendors"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/catalogue/vendors");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Save as draft' runs the save handler and closes the prompt", () => {
|
||||||
|
const onSaveDraft = vi.fn();
|
||||||
|
render(<UnsavedChangesGuard enabled onSaveDraft={onSaveDraft} saving={false} />);
|
||||||
|
fireEvent.click(addLink("/dashboard"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save as draft" }));
|
||||||
|
expect(onSaveDraft).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushMock).not.toHaveBeenCalled(); // the save action does its own redirect
|
||||||
|
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arms the browser beforeunload prompt only while dirty", () => {
|
||||||
|
const { rerender } = render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||||
|
const dirtyEvt = new Event("beforeunload", { cancelable: true });
|
||||||
|
window.dispatchEvent(dirtyEvt);
|
||||||
|
expect(dirtyEvt.defaultPrevented).toBe(true);
|
||||||
|
|
||||||
|
rerender(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
||||||
|
const cleanEvt = new Event("beforeunload", { cancelable: true });
|
||||||
|
window.dispatchEvent(cleanEvt);
|
||||||
|
expect(cleanEvt.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue