Fix magic-link verify URLs in email and related create-flow QA #70
+3
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user