import { useEffect, useLayoutEffect } from "react"; import { describe, it, expect, afterEach } from "vitest"; import { renderWithProviders as render, screen, cleanup, within, waitFor, } 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/); }); });