import { useEffect, useLayoutEffect } from "react";
import { describe, it, expect, afterEach } from "vitest";
import {
renderWithProviders as render,
screen,
cleanup,
within,
waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import type { CreateFlowState } from "../../app/(app)/create/types";
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets";
import messages from "../../messages/en/index";
afterEach(() => {
cleanup();
});
async function confirmDiscardCustomizeEdits() {
fireEvent.click(
await screen.findByRole("button", { name: "Discard" }),
);
}
async function declineDiscardCustomizeEdits() {
fireEvent.click(
await screen.findByRole("button", { name: "Keep editing" }),
);
}
/**
* Mounts the screen with optional starting state and exposes the latest
* `state` to the test harness so we can assert the persistence side of
* the Add Platform flow without driving the wizard's Next chain.
*/
const EMPTY_STATE: CreateFlowState = {};
function ScreenWithStateProbe({
onState,
initial = EMPTY_STATE,
}: {
onState: (_state: CreateFlowState) => void;
initial?: CreateFlowState;
}) {
const { state, replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
useEffect(() => {
onState(state);
}, [state, onState]);
return ;
}
/**
* Confirms the persistence half of the Add-Platform flow that lets the
* final-review chip edit modal start from a known seed instead of always
* snapping back to preset copy. See {@link CommunicationMethodEditFields}
* and `buildPublishPayload` for the read side.
*/
describe("CommunicationMethodsScreen — Add Platform persistence", () => {
it("seeds the modal from preset and persists edits + selection on Confirm", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument();
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBe(3);
const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
// Preset corePrinciple must seed into the first body textarea so the user
// edits a real starting point rather than an empty field.
expect(corePrincipleField.value.length).toBeGreaterThan(0);
fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } });
fireEvent.click(
within(dialog).getByRole("button", {
name: "Add Platform",
}),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(latest.selectedCommunicationMethodIds).toContain("signal");
});
expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple,
).toBe("Custom principle");
});
it("does not persist edits when the modal closes without Confirm", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
void dialog;
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(latest.selectedCommunicationMethodIds ?? []).not.toContain("signal");
expect(latest.communicationMethodDetailsById).toBeUndefined();
});
it("re-seeds the modal from a saved override when reopening the same chip", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
initial={{
selectedCommunicationMethodIds: ["signal"],
communicationMethodDetailsById: {
signal: {
corePrinciple: "Saved principle",
logisticsAdmin: "Saved logistics",
codeOfConduct: "Saved coc",
},
},
}}
/>,
);
void latest;
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas.length).toBe(3);
expect(textareas[0].value).toBe("Saved principle");
expect(textareas[1].value).toBe("Saved logistics");
expect(textareas[2].value).toBe("Saved coc");
});
it("persists edits and closes when Save is clicked on an already-selected platform", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
initial={{
selectedCommunicationMethodIds: ["signal"],
communicationMethodDetailsById: {
signal: {
corePrinciple: "Saved principle",
logisticsAdmin: "Saved logistics",
codeOfConduct: "Saved coc",
},
},
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textareas[0], { target: { value: "Edited principle" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple,
).toBe("Edited principle");
});
it("keeps catalog section editors when a title override exists and there are no custom fields", async () => {
const details = communicationPresetFor("video-meetings");
const noFieldsHint =
messages.create.customRule.customMethodCardWizard.editModal
.noCustomFieldsYet;
render(
{
/* noop */
}}
initial={{
selectedCommunicationMethodIds: ["video-meetings"],
customMethodCardMetaById: {
"video-meetings": {
label: "Video Meetings",
supportText:
"Synchronous video calls for remote face-to-face interaction.",
},
},
communicationMethodDetailsById: {
"video-meetings": details,
},
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", {
name: /Video Meetings: Synchronous video/,
})[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas).toHaveLength(3);
expect(textareas[0].value).toBe(details.corePrinciple);
expect(textareas[1].value).toBe(details.logisticsAdmin);
expect(textareas[2].value).toBe(details.codeOfConduct);
expect(within(dialog).queryByText(noFieldsHint)).not.toBeInTheDocument();
});
it("opens meaning fields editable without Customize", async () => {
render(
{
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas[0]).not.toBeDisabled();
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
});
it("closing the wizard without Finalize leaves the card modal and does not persist", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
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" }));
expect(
await screen.findByPlaceholderText("Policy name"),
).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument();
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(latest.customMethodCardMetaById).toBeUndefined();
});
it("Back from the wizard with edits asks to discard and does not persist", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
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");
fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } });
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(
await screen.findByRole("button", { name: "Keep editing" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
await waitFor(() => {
expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument();
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(latest.customMethodCardMetaById).toBeUndefined();
});
it("Escape after a field edit stays open when user declines discard confirm", 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: "Edited principle" } });
fireEvent.keyDown(document, { key: "Escape" });
const keepEditing = await screen.findByRole("button", {
name: "Keep editing",
});
expect(keepEditing.parentElement).toHaveClass(
"absolute",
"left-[16px]",
"top-[12px]",
);
await declineDiscardCustomizeEdits();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("Escape after a field edit discards without persisting when confirmed", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
initial={{
selectedCommunicationMethodIds: ["signal"],
communicationMethodDetailsById: {
signal: {
corePrinciple: "Saved principle",
logisticsAdmin: "Saved logistics",
codeOfConduct: "Saved coc",
},
},
}}
/>,
);
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: "Edited principle" } });
fireEvent.keyDown(document, { key: "Escape" });
await confirmDiscardCustomizeEdits();
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple,
).toBe("Saved principle");
});
it("Customize wizard prefills the card title and persists a rename on Finalize", async () => {
const customId = "00000000-0000-4000-8000-0000000000aa";
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
initial={{
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: { label: "Original title", supportText: "Sub" },
},
communicationMethodDetailsById: {
[customId]: {
corePrinciple: "p",
logisticsAdmin: "l",
codeOfConduct: "c",
},
},
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Original title/ })[0],
);
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
expect(
await screen.findByPlaceholderText("Policy name"),
).toBeInTheDocument();
const nameInput = screen.getByPlaceholderText("Policy name");
expect(nameInput).toHaveValue("Original title");
fireEvent.change(nameInput, { target: { value: "Renamed policy" } });
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Core Principle & Scope" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => {
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
"Renamed policy",
);
});
});
it("stores preset id title override in customMethodCardMetaById on Finalize", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
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" }));
expect(
await screen.findByPlaceholderText("Policy name"),
).toHaveValue("Signal");
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Core Principle & Scope" }),
);
expect(await screen.findByText("Add text block")).toBeInTheDocument();
expect(
screen.getByDisplayValue(/We prioritize privacy and security/i),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("button", { name: "Back" }));
const nameInput = await screen.findByPlaceholderText("Policy name");
expect(nameInput).toHaveValue("Signal");
fireEvent.change(nameInput, {
target: { value: "Custom Signal header" },
});
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => {
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
"Custom Signal header",
);
});
});
it("Customize Finalize shows reordered field blocks in the card modal", async () => {
let latest: CreateFlowState = {};
render(
{
latest = s;
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const cardDialog = await screen.findByRole("dialog");
fireEvent.click(
within(cardDialog).getByRole("button", { name: "More options" }),
);
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
fireEvent.click(await screen.findByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
const handles = screen.getAllByRole("button", {
name: "Drag to reorder this field",
});
const rows = screen.getAllByRole("listitem");
const store: Record = {};
const dataTransfer = {
effectAllowed: "all",
dropEffect: "move",
setData(type: string, value: string) {
store[type] = value;
},
getData(type: string) {
return store[type] ?? "";
},
};
fireEvent.pointerDown(handles[0]);
fireEvent.dragStart(rows[0], { dataTransfer });
fireEvent.drop(rows[2], { dataTransfer });
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => {
expect(
screen.queryByPlaceholderText("Policy name"),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(
latest.customMethodCardFieldBlocksById?.signal?.map((b) => b.id),
).toEqual([
"facet-logisticsAdmin",
"facet-codeOfConduct",
"facet-corePrinciple",
]);
});
const result = screen.getByRole("dialog");
const labels = within(result)
.getAllByRole("textbox")
.map((el) => {
const labelledby = el.getAttribute("aria-labelledby");
return labelledby
? (document.getElementById(labelledby)?.textContent ?? "").trim()
: "";
});
expect(labels[0]).toMatch(/Logistics, Admin/);
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);
});
});