497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
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";
|
|
|
|
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 <CommunicationMethodsScreen />;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
latest = s;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
|
);
|
|
|
|
const dialog = await screen.findByRole("dialog");
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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("opens meaning fields editable without Customize", async () => {
|
|
render(
|
|
<ScreenWithStateProbe
|
|
onState={() => {
|
|
/* 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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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(
|
|
<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: "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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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" }));
|
|
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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" }));
|
|
|
|
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(
|
|
<ScreenWithStateProbe
|
|
onState={(s) => {
|
|
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<string, string> = {};
|
|
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" }));
|
|
|
|
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/);
|
|
});
|
|
});
|