diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx index 2135a77..78c8c16 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -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 ( -
e.preventDefault()}> + e.preventDefault()} + onChange={() => setDirty(true)} + onInput={() => setDirty(true)} + > {canResubmit && (

@@ -238,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN

Line 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 )}
- @@ -324,6 +336,14 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN )}
+ + handleSubmit("save")} + onDiscard={leave} + onStay={stay} + saveLabel="Save Draft" + /> ); } diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index ecf4be2..c3d1edb 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -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 ( -
e.preventDefault()}> + e.preventDefault()} + onChange={() => setDirty(true)} + onInput={() => setDirty(true)} + > {/* Order Information */}

Order Information

@@ -208,7 +220,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt

Line 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 */}

Attachments (optional)

- + { setFiles(f); setDirty(true); }} disabled={!!submitting} />
{error && ( @@ -297,6 +309,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt {submitting === "submit" ? "Submitting…" : "Submit for Approval"}
+ + handleSubmit("draft")} + onDiscard={leave} + onStay={stay} + /> ); } diff --git a/App/components/po/unsaved-changes-dialog.tsx b/App/components/po/unsaved-changes-dialog.tsx new file mode 100644 index 0000000..222c5bf --- /dev/null +++ b/App/components/po/unsaved-changes-dialog.tsx @@ -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 ( + +
+

+ You have unsaved changes to this purchase order. Would you like to + save it as a draft before leaving? +

+ {error && ( +

{error}

+ )} +
+ + + +
+
+
+ ); +} diff --git a/App/components/po/use-unsaved-changes.ts b/App/components/po/use-unsaved-changes.ts new file mode 100644 index 0000000..15f572e --- /dev/null +++ b/App/components/po/use-unsaved-changes.ts @@ -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 void)>(null); + const pendingRef = useRef 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 }; +} diff --git a/App/tests/unit/unsaved-changes.test.tsx b/App/tests/unit/unsaved-changes.test.tsx new file mode 100644 index 0000000..b9290e6 --- /dev/null +++ b/App/tests/unit/unsaved-changes.test.tsx @@ -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( + + ); + 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( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("invokes onDiscard / onStay when those buttons are clicked", () => { + const onDiscard = vi.fn(); + const onStay = vi.fn(); + render( + + ); + 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( + + ); + 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( + + ); + expect(screen.getByRole("button", { name: "Save Draft" })).toBeInTheDocument(); + }); +}); + +// ── Hook ──────────────────────────────────────────────────────────────────── + +function Harness({ dirty }: { dirty: boolean }) { + const { promptOpen, guard, leave, stay } = useUnsavedChanges(dirty); + return ( +
+ {String(promptOpen)} + internal link + same page anchor + external link + + + +
+ ); +} + +describe("useUnsavedChanges", () => { + it("intercepts internal link clicks while dirty", () => { + render(); + 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(); + fireEvent.click(screen.getByText("internal link")); + expect(screen.getByTestId("open")).toHaveTextContent("false"); + }); + + it("ignores external links", () => { + render(); + fireEvent.click(screen.getByText("external link")); + expect(screen.getByTestId("open")).toHaveTextContent("false"); + }); + + it("intercepts guarded programmatic navigation while dirty, then proceeds on leave", () => { + render(); + 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(); + fireEvent.click(screen.getByText("cancel")); + expect(push).toHaveBeenCalledWith("/programmatic"); + expect(screen.getByTestId("open")).toHaveTextContent("false"); + }); + + it("closes the prompt without navigating on stay", () => { + render(); + 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(); + const evt = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(evt); + expect(evt.defaultPrevented).toBe(true); + }); + + it("does not warn via beforeunload when clean", () => { + render(); + const evt = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(evt); + expect(evt.defaultPrevented).toBe(false); + }); +});