Fix magic-link verify URLs in email and related create-flow QA #70

Merged
an.di merged 4 commits from adilallo/fix/QaFeedback into main 2026-09-01 16:09:31 +00:00
19 changed files with 350 additions and 1 deletions
Showing only changes of commit 7f67cc6271 - Show all commits
@@ -6,6 +6,7 @@ import {
useTranslation, useTranslation,
} from "../../../../contexts/MessagesContext"; } from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
@@ -213,6 +214,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyTitle, policyTitle,
]); ]);
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
const confirmAbandonWizardEdits = useCallback(async () => { const confirmAbandonWizardEdits = useCallback(async () => {
if (!isWizardSessionDirty()) { if (!isWizardSessionDirty()) {
return true; return true;
@@ -28,6 +28,7 @@ import {
import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import { import {
communicationPresetFor, communicationPresetFor,
conflictManagementPresetFor, conflictManagementPresetFor,
@@ -370,6 +371,12 @@ export function FinalReviewChipEditModal({
draftFieldBlocks, draftFieldBlocks,
]); ]);
useBeforeUnloadGuard(
isOpen &&
!addCustomWizardOpen &&
(!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled),
);
const modalUsesWizardFieldBlocksBody = Boolean( const modalUsesWizardFieldBlocksBody = Boolean(
target && target &&
(usesWizardFieldBlocksModalBody({ (usesWizardFieldBlocksModalBody({
@@ -6,6 +6,7 @@
*/ */
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput"; import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup"; import ContentLockup from "../../../components/type/ContentLockup";
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
[draft], [draft],
); );
useBeforeUnloadGuard(isOpen && isDirty);
const characterHint = tField("characterCountTemplate") const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length)) .replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH)); .replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
@@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -57,6 +58,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -88,6 +90,16 @@ export function CommunicationMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedCommunicationMethodIds ?? []; const selectedIds = state.selectedCommunicationMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -54,6 +55,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -85,6 +87,16 @@ export function ConflictManagementScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedConflictManagementIds ?? []; const selectedIds = state.selectedConflictManagementIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -55,6 +56,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -86,6 +88,16 @@ export function MembershipMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedMembershipMethodIds ?? []; const selectedIds = state.selectedMembershipMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -91,6 +93,16 @@ export function DecisionApproachesScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({ const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById, detailsById: state.decisionApproachDetailsById,
@@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { import type {
CommunityStructureChipSnapshotRow, CommunityStructureChipSnapshotRow,
CoreValueDetailEntry, CoreValueDetailEntry,
@@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() {
}); });
}, [cv.detailModal, draft, modalSession, requestConfirm]); }, [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(() => { const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
+1
View File
@@ -18,6 +18,7 @@ export { useMediaQuery,
} from "./useMediaQuery"; } from "./useMediaQuery";
export { useAsyncConfirm } from "./useAsyncConfirm"; export { useAsyncConfirm } from "./useAsyncConfirm";
export type { AsyncConfirmOptions } from "./useAsyncConfirm"; export type { AsyncConfirmOptions } from "./useAsyncConfirm";
export { useBeforeUnloadGuard } from "./useBeforeUnloadGuard";
export type { export type {
SchemaOrganization, SchemaOrganization,
SchemaWebSite, SchemaWebSite,
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
/**
* Attach the browsers 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]);
}
+22
View File
@@ -25,6 +25,28 @@ export function captureMethodCardCustomizeSnapshot<TDraft>(
}; };
} }
/**
* 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<TDraft>(
modalOpen: boolean,
snapshot: MethodCardCustomizeSnapshot<TDraft> | 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<TDraft>( export function isMethodCardCustomizeSessionDirty<TDraft>(
snapshot: MethodCardCustomizeSnapshot<TDraft>, snapshot: MethodCardCustomizeSnapshot<TDraft>,
pendingDraft: TDraft | null, pendingDraft: TDraft | null,
@@ -6,6 +6,7 @@ import {
cleanup, cleanup,
within, within,
waitFor, waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils"; } from "../utils/test-utils";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
@@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect(labels[1]).toMatch(/Code of Conduct/); expect(labels[1]).toMatch(/Code of Conduct/);
expect(labels[2]).toMatch(/Core Principle/); expect(labels[2]).toMatch(/Core Principle/);
}); });
it("does not block tab close when the create modal is unchanged", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* 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(
<ScreenWithStateProbe
onState={() => {
/* 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(
<ScreenWithStateProbe
onState={() => {
/* 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);
});
}); });
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor, within } from "@testing-library/react"; import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; 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"; import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen";
describe("CoreValuesSelectScreen", () => { describe("CoreValuesSelectScreen", () => {
@@ -65,6 +65,20 @@ describe("CoreValuesSelectScreen", () => {
}); });
}); });
it("does not block tab close when a pending value is unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks tab close after editing a pending value", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
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 () => { it("keeps the pending value modal open when Keep editing is chosen", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
@@ -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(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
await screen.findByPlaceholderText("Policy name");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks unload after the user types a policy name", async () => {
render(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
const name = await screen.findByPlaceholderText("Policy name");
fireEvent.change(name, { target: { value: "Garden hours" } });
expect(dispatchBeforeUnload()).toBe(true);
});
});
+23
View File
@@ -5,6 +5,7 @@ import {
renderWithProviders as render, renderWithProviders as render,
screen, screen,
waitFor, waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils"; } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
@@ -623,6 +624,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
expect(latest.communicationMethodDetailsById).toBeUndefined(); expect(latest.communicationMethodDetailsById).toBeUndefined();
}); });
it("blocks tab close while chip edits are unsaved", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {
/* 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 () => { it("shows consolidated placeholder for user-authored communication chips", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000"; const customId = "550e8400-e29b-41d4-a716-446655440000";
render( render(
@@ -4,6 +4,7 @@ import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession, confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty, isMethodCardCustomizeSessionDirty,
isMethodCardCustomizeUnloadBlocked,
} from "../../lib/create/methodCardCustomizeSession"; } from "../../lib/create/methodCardCustomizeSession";
const HEADER_0 = { title: "", description: "" }; const HEADER_0 = { title: "", description: "" };
@@ -88,4 +89,24 @@ describe("methodCardCustomizeSession", () => {
).toBe(false); ).toBe(false);
expect(confirmFn).toHaveBeenCalled(); 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);
});
}); });
@@ -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);
});
});
@@ -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);
});
});
+7
View File
@@ -25,5 +25,12 @@ export function renderWithProviders(
return render(ui, { wrapper: Wrapper, ...options }); 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 // Re-export everything from @testing-library/react for convenience
export * from "@testing-library/react"; export * from "@testing-library/react";