diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx index 4c5f8bd..f412c9d 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -6,6 +6,7 @@ import { useTranslation, } from "../../../../contexts/MessagesContext"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, @@ -213,6 +214,8 @@ const CustomMethodCardWizardContainer = memo( policyTitle, ]); + useBeforeUnloadGuard(isOpen && isWizardSessionDirty()); + const confirmAbandonWizardEdits = useCallback(async () => { if (!isWizardSessionDirty()) { return true; diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx index 266f739..2367b15 100644 --- a/app/(app)/create/components/FinalReviewChipEditModal.tsx +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -28,6 +28,7 @@ import { import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; +import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard"; import { communicationPresetFor, conflictManagementPresetFor, @@ -370,6 +371,12 @@ export function FinalReviewChipEditModal({ draftFieldBlocks, ]); + useBeforeUnloadGuard( + isOpen && + !addCustomWizardOpen && + (!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled), + ); + const modalUsesWizardFieldBlocksBody = Boolean( target && (usesWizardFieldBlocksModalBody({ diff --git a/app/(app)/create/components/FinalReviewCommunityContextEditModal.tsx b/app/(app)/create/components/FinalReviewCommunityContextEditModal.tsx index d3691df..5df8827 100644 --- a/app/(app)/create/components/FinalReviewCommunityContextEditModal.tsx +++ b/app/(app)/create/components/FinalReviewCommunityContextEditModal.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; +import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard"; import Create from "../../../components/modals/Create"; import TextInput from "../../../components/controls/TextInput"; import ContentLockup from "../../../components/type/ContentLockup"; @@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({ [draft], ); + useBeforeUnloadGuard(isOpen && isDirty); + const characterHint = tField("characterCountTemplate") .replace("{current}", String(draft.length)) .replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH)); diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 7a46781..928a771 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; @@ -57,6 +58,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -88,6 +90,16 @@ export function CommunicationMethodsScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedCommunicationMethodIds ?? []; const mergedMethods = useMemo( diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 972ce08..7917e3b 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; @@ -54,6 +55,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -85,6 +87,16 @@ export function ConflictManagementScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedConflictManagementIds ?? []; const mergedMethods = useMemo( diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 1a3dd30..55a3cc1 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; @@ -55,6 +56,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -86,6 +88,16 @@ export function MembershipMethodsScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedMembershipMethodIds ?? []; const mergedMethods = useMemo( diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index 7b71481..3dbbd8c 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; @@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -91,6 +93,16 @@ export function DecisionApproachesScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedDecisionApproachIds ?? []; const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({ detailsById: state.decisionApproachDetailsById, diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 743c044..4ca6a8a 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import type { CommunityStructureChipSnapshotRow, CoreValueDetailEntry, @@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() { }); }, [cv.detailModal, draft, modalSession, requestConfirm]); + useBeforeUnloadGuard( + activeModalChipId != null && + !addCustomWizardOpen && + initialDraftRef.current != null && + (draft.meaning !== initialDraftRef.current.meaning || + draft.signals !== initialDraftRef.current.signals), + ); + const handleDuplicateCoreChip = useCallback(() => { if (!activeModalChipId || !modalSession) return; markCreateFlowInteraction(); diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 690f43e..256749d 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -18,6 +18,7 @@ export { useMediaQuery, } from "./useMediaQuery"; export { useAsyncConfirm } from "./useAsyncConfirm"; export type { AsyncConfirmOptions } from "./useAsyncConfirm"; +export { useBeforeUnloadGuard } from "./useBeforeUnloadGuard"; export type { SchemaOrganization, SchemaWebSite, diff --git a/app/hooks/useBeforeUnloadGuard.ts b/app/hooks/useBeforeUnloadGuard.ts new file mode 100644 index 0000000..27215b0 --- /dev/null +++ b/app/hooks/useBeforeUnloadGuard.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Attach the browser’s native leave-site prompt while `enabled` is true. + * + * Modern browsers ignore custom copy; this only blocks accidental tab or + * window close. No-ops during SSR and when `enabled` is false so clean + * sessions never register a listener. + * + * @param enabled Whether unsaved work would be lost on unload. + * + * @example + * useBeforeUnloadGuard(isWizardSessionDirty()); + */ +export function useBeforeUnloadGuard(enabled: boolean): void { + useEffect(() => { + if (!enabled || typeof window === "undefined") { + return; + } + + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [enabled]); +} diff --git a/lib/create/methodCardCustomizeSession.ts b/lib/create/methodCardCustomizeSession.ts index 67b8727..636921e 100644 --- a/lib/create/methodCardCustomizeSession.ts +++ b/lib/create/methodCardCustomizeSession.ts @@ -25,6 +25,28 @@ export function captureMethodCardCustomizeSnapshot( }; } +/** + * True when a method-card create/customize modal is open with edits that + * are not yet persisted — the condition for a tab/window `beforeunload` guard. + */ +export function isMethodCardCustomizeUnloadBlocked( + modalOpen: boolean, + snapshot: MethodCardCustomizeSnapshot | null, + pendingDraft: TDraft | null, + draftFieldBlocks: CustomMethodCardFieldBlock[] | null, + headerDraft: MethodCardHeaderDraft | null, +): boolean { + if (!modalOpen || snapshot === null) { + return false; + } + return isMethodCardCustomizeSessionDirty( + snapshot, + pendingDraft, + draftFieldBlocks, + headerDraft, + ); +} + export function isMethodCardCustomizeSessionDirty( snapshot: MethodCardCustomizeSnapshot, pendingDraft: TDraft | null, diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index 9ec30d7..bd32324 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -6,6 +6,7 @@ import { cleanup, within, waitFor, + dispatchBeforeUnload, } from "../utils/test-utils"; import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; @@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { expect(labels[1]).toMatch(/Code of Conduct/); expect(labels[2]).toMatch(/Core Principle/); }); + + it("does not block tab close when the create modal is unchanged", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks tab close while the create modal has unsaved field edits", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + const textboxes = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); + + it("blocks tab close while the custom-policy wizard has unsaved edits", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const nameInput = await screen.findByPlaceholderText("Policy name"); + expect(dispatchBeforeUnload()).toBe(false); + fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); }); diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx index 1861485..05b409e 100644 --- a/tests/components/CoreValuesSelectScreen.test.tsx +++ b/tests/components/CoreValuesSelectScreen.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, fireEvent, waitFor, within } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; -import { renderWithProviders } from "../utils/test-utils"; +import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils"; import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen"; describe("CoreValuesSelectScreen", () => { @@ -65,6 +65,20 @@ describe("CoreValuesSelectScreen", () => { }); }); + it("does not block tab close when a pending value is unchanged", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks tab close after editing a pending value", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + await editMeaningInOpenDialog("Changed meaning"); + expect(dispatchBeforeUnload()).toBe(true); + }); + it("keeps the pending value modal open when Keep editing is chosen", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); diff --git a/tests/components/CustomMethodCardWizardUnload.test.tsx b/tests/components/CustomMethodCardWizardUnload.test.tsx new file mode 100644 index 0000000..9737412 --- /dev/null +++ b/tests/components/CustomMethodCardWizardUnload.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { + renderWithProviders as render, + screen, + fireEvent, + dispatchBeforeUnload, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import CustomMethodCardWizard from "../../app/(app)/create/components/CustomMethodCardWizard"; + +describe("CustomMethodCardWizard — tab close guard", () => { + it("does not block unload when the wizard is open but unchanged", async () => { + render( + { + /* noop */ + }} + onFinalize={() => { + /* noop */ + }} + />, + ); + await screen.findByPlaceholderText("Policy name"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks unload after the user types a policy name", async () => { + render( + { + /* noop */ + }} + onFinalize={() => { + /* noop */ + }} + />, + ); + const name = await screen.findByPlaceholderText("Policy name"); + fireEvent.change(name, { target: { value: "Garden hours" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); +}); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 8ba5ac3..22b6c47 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -5,6 +5,7 @@ import { renderWithProviders as render, screen, waitFor, + dispatchBeforeUnload, } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; @@ -623,6 +624,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { expect(latest.communicationMethodDetailsById).toBeUndefined(); }); + it("blocks tab close while chip edits are unsaved", async () => { + render( + { + /* noop */ + }} + initial={baseSelections} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); + fireEvent.change(principleField, { + target: { value: "Unsaved on tab close" }, + }); + expect(dispatchBeforeUnload()).toBe(true); + }); + it("shows consolidated placeholder for user-authored communication chips", async () => { const customId = "550e8400-e29b-41d4-a716-446655440000"; render( diff --git a/tests/lib/methodCardCustomizeSession.test.ts b/tests/lib/methodCardCustomizeSession.test.ts index 1b67331..0c74230 100644 --- a/tests/lib/methodCardCustomizeSession.test.ts +++ b/tests/lib/methodCardCustomizeSession.test.ts @@ -4,6 +4,7 @@ import { captureMethodCardCustomizeSnapshot, confirmDiscardMethodCardCustomizeSession, isMethodCardCustomizeSessionDirty, + isMethodCardCustomizeUnloadBlocked, } from "../../lib/create/methodCardCustomizeSession"; const HEADER_0 = { title: "", description: "" }; @@ -88,4 +89,24 @@ describe("methodCardCustomizeSession", () => { ).toBe(false); expect(confirmFn).toHaveBeenCalled(); }); + + it("unload block is false when the modal is closed or snapshot is missing", () => { + const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0); + expect( + isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0), + ).toBe(false); + expect( + isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0), + ).toBe(false); + }); + + it("unload block is true only when the open modal is dirty", () => { + const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0); + expect( + isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0), + ).toBe(false); + expect( + isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0), + ).toBe(true); + }); }); diff --git a/tests/unit/hooks/useBeforeUnloadGuard.test.ts b/tests/unit/hooks/useBeforeUnloadGuard.test.ts new file mode 100644 index 0000000..e651e1b --- /dev/null +++ b/tests/unit/hooks/useBeforeUnloadGuard.test.ts @@ -0,0 +1,26 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useBeforeUnloadGuard } from "../../../app/hooks/useBeforeUnloadGuard"; +import { dispatchBeforeUnload } from "../../utils/test-utils"; + +describe("useBeforeUnloadGuard", () => { + it("does not intercept unload when disabled", () => { + renderHook(() => useBeforeUnloadGuard(false)); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("prevents unload when enabled", () => { + renderHook(() => useBeforeUnloadGuard(true)); + expect(dispatchBeforeUnload()).toBe(true); + }); + + it("drops the listener when enabled flips to false", () => { + const { rerender } = renderHook( + ({ enabled }) => useBeforeUnloadGuard(enabled), + { initialProps: { enabled: true } }, + ); + expect(dispatchBeforeUnload()).toBe(true); + rerender({ enabled: false }); + expect(dispatchBeforeUnload()).toBe(false); + }); +}); diff --git a/tests/unit/methodCardCustomizeUnloadBlocked.test.ts b/tests/unit/methodCardCustomizeUnloadBlocked.test.ts new file mode 100644 index 0000000..8472ecc --- /dev/null +++ b/tests/unit/methodCardCustomizeUnloadBlocked.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, +} from "../../lib/create/methodCardCustomizeSession"; + +const HEADER_0 = { title: "", description: "" }; + +describe("isMethodCardCustomizeUnloadBlocked", () => { + it("is false when the modal is closed or snapshot is missing", () => { + const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0); + expect( + isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0), + ).toBe(false); + expect( + isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0), + ).toBe(false); + }); + + it("is true only when the open modal is dirty", () => { + const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0); + expect( + isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0), + ).toBe(false); + expect( + isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0), + ).toBe(true); + }); +}); diff --git a/tests/utils/test-utils.tsx b/tests/utils/test-utils.tsx index 34b950e..f6243a5 100644 --- a/tests/utils/test-utils.tsx +++ b/tests/utils/test-utils.tsx @@ -25,5 +25,12 @@ export function renderWithProviders( return render(ui, { wrapper: Wrapper, ...options }); } +/** True when a `beforeunload` listener called `preventDefault` (tab-close guard). */ +export function dispatchBeforeUnload(): boolean { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +} + // Re-export everything from @testing-library/react for convenience export * from "@testing-library/react";