Custom add and create flow polish
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
@@ -63,17 +63,22 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textareas = within(dialog).getAllByRole("textbox");
|
||||
expect(textareas.length).toBe(3);
|
||||
// Preset corePrinciple must seed into the first textarea so the user
|
||||
// edits a real starting point rather than an empty field.
|
||||
expect((textareas[0] as HTMLTextAreaElement).value.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
fireEvent.change(textareas[0], { target: { value: "Custom principle" } });
|
||||
const textboxes = within(screen.getByRole("dialog")).getAllByRole("textbox");
|
||||
expect(textboxes.length).toBe(5);
|
||||
const corePrincipleField = textboxes[2] 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: "Save" }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: "Add Platform" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "Add Platform",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -101,11 +106,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
void dialog;
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
@@ -143,8 +144,201 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
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("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockImplementation(() => {
|
||||
throw new Error("confirm should not run when customize session is clean");
|
||||
});
|
||||
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" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
(within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement).disabled,
|
||||
).toBe(true);
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("Cancel customize with edits restores snapshot after confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
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");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const textboxes = within(dialog).getAllByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement[];
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Saved principle");
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Saved principle");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dirty Escape close stays open when user declines discard confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
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 textboxes = within(dialog).getAllByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement[];
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists customized policy title for a custom UUID card on Save", 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" }));
|
||||
|
||||
const titleInput = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Renamed policy" } });
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
|
||||
"Renamed policy",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores preset id title override in customMethodCardMetaById on Save", 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 titleInput = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLInputElement;
|
||||
fireEvent.change(titleInput, {
|
||||
target: { value: "Custom Signal header" },
|
||||
});
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
||||
"Custom Signal header",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CustomMethodCardFieldBlocksSummary from "../../app/(app)/create/components/CustomMethodCardFieldBlocksSummary";
|
||||
import messages from "../../messages/en/index";
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const uploadCopy =
|
||||
messages.create.customRule.customMethodCardWizard.fieldModals.upload;
|
||||
|
||||
describe("CustomMethodCardFieldBlocksSummary", () => {
|
||||
it("hides Upload when an upload block already has assetUrl; shows preview and remove control", () => {
|
||||
const onBlocksChange = vi.fn();
|
||||
render(
|
||||
<CustomMethodCardFieldBlocksSummary
|
||||
blocks={[
|
||||
{
|
||||
kind: "upload",
|
||||
id: "u1",
|
||||
blockTitle: "Attachment",
|
||||
fileName: "photo.png",
|
||||
assetUrl: "/api/uploads/test-id",
|
||||
},
|
||||
]}
|
||||
onBlocksChange={onBlocksChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: uploadCopy.uploadPreviewImageAlt }),
|
||||
).toHaveAttribute("src", "/api/uploads/test-id");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("after remove, parent can pass cleared blocks and Upload shows again", () => {
|
||||
function Harness() {
|
||||
const [blocks, setBlocks] = useState<CustomMethodCardFieldBlock[]>([
|
||||
{
|
||||
kind: "upload",
|
||||
id: "u1",
|
||||
blockTitle: "Attachment",
|
||||
fileName: "photo.png",
|
||||
assetUrl: "/api/uploads/test-id",
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<CustomMethodCardFieldBlocksSummary
|
||||
blocks={blocks}
|
||||
onBlocksChange={setBlocks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CustomMethodCardModalBody from "../../app/(app)/create/components/CustomMethodCardModalBody";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const wizard = messages.create.customRule.customMethodCardWizard;
|
||||
|
||||
describe("CustomMethodCardModalBody", () => {
|
||||
it("with meta and no blocks, shows policy title, description, and no-fields hint", () => {
|
||||
render(
|
||||
<CustomMethodCardModalBody
|
||||
cardId="c1"
|
||||
blocksById={{}}
|
||||
policyMeta={{ label: "Our policy", supportText: "How we work" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Our policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("How we work")).toBeInTheDocument();
|
||||
expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("with meta and no blocks in customize mode, omits duplicate ContentLockup but keeps hint", () => {
|
||||
render(
|
||||
<CustomMethodCardModalBody
|
||||
cardId="c1"
|
||||
blocksById={{}}
|
||||
policyMeta={{ label: "T", supportText: "D" }}
|
||||
showPolicyContentLockupWhenNoBlocks={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("T")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("D")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("without meta, falls back to placeholder", () => {
|
||||
render(<CustomMethodCardModalBody cardId="c1" blocksById={{}} />);
|
||||
|
||||
expect(screen.getByText(wizard.editModal.placeholderBody)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
@@ -185,6 +185,26 @@ describe("FinalReviewScreen — prefilled selections", () => {
|
||||
});
|
||||
|
||||
describe("FinalReviewScreen — chip detail modal", () => {
|
||||
async function enterMethodCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
async function enterCoreValueCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
it("opens the read-only detail modal when a chip is clicked, matching the preset copy", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
|
||||
@@ -208,6 +228,83 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("method chip modal kebab offers Customize but not Duplicate", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^customize$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /^duplicate$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("values chip modal kebab offers Customize and Duplicate under the cap", async () => {
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
render(<CoreValuesHarness />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^customize$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^duplicate$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens method chip modal read-only until Customize, then enables Save after an edit", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
expect(principleField).toBeDisabled();
|
||||
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.change(principleField, { target: { value: "Edited principle" } });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a core-values chip with the matching preset meaning/signals", async () => {
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
@@ -236,7 +333,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip (parity with method chips)", async () => {
|
||||
it("opens the editable Save modal for a values chip after Customize", async () => {
|
||||
// Customize / plain custom-rule path: snapshot is set, sections is not.
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
@@ -256,6 +353,10 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
await enterCoreValueCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -264,7 +365,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip in the use-without-changes flow", async () => {
|
||||
it("opens Save for values chip after Customize (use-without-changes seeded snapshot)", async () => {
|
||||
// Mirrors the post-fix payload from `handleUseTemplateWithoutChanges`:
|
||||
// template Values section is stripped from `sections`, snapshot +
|
||||
// selected ids are seeded so the chip carries an `overrideKey`.
|
||||
@@ -294,6 +395,10 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
await enterCoreValueCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -312,6 +417,16 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
* 3. Closing without Save discards every typed change.
|
||||
*/
|
||||
describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
async function enterMethodCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
const baseSelections: CreateFlowState = {
|
||||
title: "Oak Park Commons",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
@@ -331,11 +446,14 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const saveButton = within(dialog).getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
|
||||
@@ -359,14 +477,21 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).queryByRole("button", {
|
||||
name: "Save",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -388,15 +513,23 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
try {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
confirmSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
@@ -424,11 +557,41 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).getByText(/title and description you set/i),
|
||||
within(dialog).getByText(/no custom fields yet/i),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows custom communication chip when template sections exist (customize-from-template)", async () => {
|
||||
const customId = "550e8400-e29b-41d4-a716-446655440999";
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={() => {}}
|
||||
initial={{
|
||||
title: "Oak Park Commons",
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["signal", customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: {
|
||||
label: "Garden IRC",
|
||||
supportText: "Support line from wizard",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Garden IRC" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Signal" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows editable field blocks for user-authored communication chips when configured", async () => {
|
||||
const customId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
render(
|
||||
@@ -461,12 +624,13 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
await screen.findByRole("button", { name: "Custom Comm" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).queryByText(/title and description you set/i),
|
||||
within(dialog).queryByText(/no custom fields yet/i),
|
||||
).not.toBeInTheDocument();
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
expect(textarea).not.toBeDisabled();
|
||||
expect(textarea).toHaveValue("Detail here");
|
||||
const notesField = within(dialog).getByRole("textbox", { name: /notes/i });
|
||||
expect(notesField).not.toBeDisabled();
|
||||
expect(notesField).toHaveValue("Detail here");
|
||||
});
|
||||
|
||||
it("persists field block edits for user-authored communication chips on Save", async () => {
|
||||
@@ -504,19 +668,20 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
await screen.findByRole("button", { name: "Custom Comm" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
fireEvent.change(textarea, { target: { value: "Saved detail" } });
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const notesField = within(dialog).getByRole("textbox", { name: /notes/i });
|
||||
fireEvent.change(notesField, { target: { value: "Saved detail" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
latest.customMethodCardFieldBlocksById?.[customId]?.[0],
|
||||
).toMatchObject({
|
||||
kind: "text",
|
||||
placeholderText: "Saved detail",
|
||||
expect(
|
||||
latest.customMethodCardFieldBlocksById?.[customId]?.[0],
|
||||
).toMatchObject({
|
||||
kind: "text",
|
||||
placeholderText: "Saved detail",
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
} from "../../lib/create/methodCardCustomizeSession";
|
||||
|
||||
const HEADER_0 = { title: "", description: "" };
|
||||
|
||||
describe("methodCardCustomizeSession", () => {
|
||||
it("reports clean session when pendingDraft and blocks match snapshot", () => {
|
||||
const draft = { a: 1, b: [2] };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { ...draft }, null, HEADER_0),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports dirty when pendingDraft JSON differs", () => {
|
||||
const snap = captureMethodCardCustomizeSnapshot({ x: "one" }, null, HEADER_0);
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { x: "two" }, null, HEADER_0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dirty when field blocks differ", () => {
|
||||
const before: CustomMethodCardFieldBlock[] = [
|
||||
{ kind: "text", id: "b1", blockTitle: "t", placeholderText: "" },
|
||||
];
|
||||
const snap = captureMethodCardCustomizeSnapshot({ ok: true }, before, HEADER_0);
|
||||
const after: CustomMethodCardFieldBlock[] = [
|
||||
{
|
||||
kind: "text",
|
||||
id: "b1",
|
||||
blockTitle: "t",
|
||||
placeholderText: "edited",
|
||||
},
|
||||
];
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { ok: true }, after, HEADER_0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dirty when header draft differs", () => {
|
||||
const snap = captureMethodCardCustomizeSnapshot({ ok: true }, null, {
|
||||
title: "A",
|
||||
description: "B",
|
||||
});
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
{ ok: true },
|
||||
null,
|
||||
{ title: "A2", description: "B" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", () => {
|
||||
const spy = vi.spyOn(window, "confirm");
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
null,
|
||||
{ x: 1 },
|
||||
null,
|
||||
null,
|
||||
"msg",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("confirmDiscard runs confirm when dirty", () => {
|
||||
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const draft = { n: 1 };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
snap,
|
||||
{ n: 2 },
|
||||
null,
|
||||
HEADER_0,
|
||||
"Discard?",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(spy).toHaveBeenCalledWith("Discard?");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ describe("Create flow communication-methods page", () => {
|
||||
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("re-opening a selected method shows Remove as the modal primary action", async () => {
|
||||
test("re-opening a selected method shows no modal primary; Remove is in the kebab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -54,11 +54,17 @@ describe("Create flow communication-methods page", () => {
|
||||
await user.click(signalCards[0]);
|
||||
const dialogAgain = screen.getByRole("dialog");
|
||||
expect(
|
||||
within(dialogAgain).getByRole("button", { name: "Remove" }),
|
||||
).toBeInTheDocument();
|
||||
within(dialogAgain).queryByRole("button", { name: "Remove" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialogAgain).queryByRole("button", { name: "Add Platform" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Remove in the modal deselects the method", async () => {
|
||||
test("Remove from the kebab deselects the method", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -76,12 +82,50 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
await user.click(signalCards[0]);
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "More options",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||
|
||||
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
|
||||
});
|
||||
|
||||
test("kebab menu does not include Close", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const signalCards = screen.getAllByRole("button", {
|
||||
name: /Signal: Encrypted messaging/,
|
||||
});
|
||||
await user.click(signalCards[0]);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Close" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("unselected preset method fields are disabled until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const signalCards = screen.getAllByRole("button", {
|
||||
name: /Signal: Encrypted messaging/,
|
||||
});
|
||||
await user.click(signalCards[0]);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const textbox = within(dialog).getAllByRole("textbox")[0];
|
||||
expect(textbox).toBeDisabled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getAllByRole("textbox")[0],
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("renders without error", () => {
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -152,7 +196,7 @@ describe("Create flow communication-methods page", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opening Create modal for custom policy shows saved field blocks", async () => {
|
||||
test("opening Create modal for custom policy shows saved field blocks read-only until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
@@ -180,12 +224,23 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
expect(textarea).not.toBeDisabled();
|
||||
const textboxesBefore = within(dialog).getAllByRole("textbox");
|
||||
expect(textboxesBefore).toHaveLength(1);
|
||||
const textarea = textboxesBefore[0];
|
||||
expect(textarea).toBeDisabled();
|
||||
expect(textarea).toHaveValue("Enter norms here");
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const guidelinesAfter = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[2];
|
||||
expect(guidelinesAfter).not.toBeDisabled();
|
||||
expect(guidelinesAfter).toHaveValue("Enter norms here");
|
||||
});
|
||||
|
||||
test("opening Create modal for custom policy shows badge options as chips", async () => {
|
||||
test("opening Create modal for custom policy shows badge options as chips read-only until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
@@ -213,13 +268,25 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Choose channels")).toBeInTheDocument();
|
||||
const alpha = within(dialog).getByRole("button", { name: /Deselect Alpha/ });
|
||||
const beta = within(dialog).getByRole("button", { name: /Deselect Beta/ });
|
||||
expect(alpha).not.toBeDisabled();
|
||||
expect(beta).not.toBeDisabled();
|
||||
const alpha = within(dialog).getByRole("button", { name: /^Alpha$/ });
|
||||
const beta = within(dialog).getByRole("button", { name: /^Beta$/ });
|
||||
expect(alpha).toBeDisabled();
|
||||
expect(beta).toBeDisabled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const alphaAfter = within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: /Deselect Alpha/,
|
||||
});
|
||||
const betaAfter = within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: /Deselect Beta/,
|
||||
});
|
||||
expect(alphaAfter).not.toBeDisabled();
|
||||
expect(betaAfter).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("editing custom policy field blocks updates draft state", async () => {
|
||||
test("editing custom policy field blocks updates draft state after Save", async () => {
|
||||
const user = userEvent.setup();
|
||||
let latest = {};
|
||||
function Probe({ initial }) {
|
||||
@@ -254,10 +321,16 @@ describe("Create flow communication-methods page", () => {
|
||||
name: /My policy: Support copy/,
|
||||
});
|
||||
await user.click(policyTiles[0]);
|
||||
const textarea = within(screen.getByRole("dialog")).getByRole("textbox");
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const textarea = within(dialog).getAllByRole("textbox")[2];
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Updated norms");
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
const row = latest.customMethodCardFieldBlocksById?.[CUSTOM_POLICY_ID]?.[0];
|
||||
expect(row).toMatchObject({
|
||||
kind: "text",
|
||||
@@ -265,4 +338,83 @@ describe("Create flow communication-methods page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("duplicate staged copy is unselected; closing modal drops ephemeral card; duplicate again works", async () => {
|
||||
const user = userEvent.setup();
|
||||
let latest = {};
|
||||
function Probe({ initial }) {
|
||||
const { replaceState, state } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState(initial);
|
||||
}, [replaceState, initial]);
|
||||
useLayoutEffect(() => {
|
||||
latest = state;
|
||||
}, [state]);
|
||||
return <CommunicationMethodsScreen />;
|
||||
}
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
customMethodCardMetaById: {
|
||||
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[CUSTOM_POLICY_ID]: [
|
||||
{
|
||||
kind: "text",
|
||||
id: "f1",
|
||||
blockTitle: "Guidelines",
|
||||
placeholderText: "Enter norms here",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
render(<Probe initial={initial} />);
|
||||
|
||||
const policyTiles = screen.getAllByRole("button", {
|
||||
name: /My policy: Support copy/,
|
||||
});
|
||||
await user.click(policyTiles[0]);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Duplicate" }));
|
||||
|
||||
const metaAfterDup = latest.customMethodCardMetaById ?? {};
|
||||
const dupIds = Object.keys(metaAfterDup).filter(
|
||||
(id) => id !== CUSTOM_POLICY_ID,
|
||||
);
|
||||
expect(dupIds).toHaveLength(1);
|
||||
const dupId = dupIds[0];
|
||||
expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Add Platform" }),
|
||||
).toBeInTheDocument();
|
||||
expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true);
|
||||
expect(within(dialog).getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
/^My policy \(copy\)$/,
|
||||
);
|
||||
expect(within(dialog).getByText("Support copy")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("textbox")).toHaveValue("Enter norms here");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "Close dialog" }),
|
||||
);
|
||||
|
||||
const metaAfterClose = latest.customMethodCardMetaById ?? {};
|
||||
expect(metaAfterClose[dupId]).toBeUndefined();
|
||||
expect(Object.keys(metaAfterClose)).toEqual([CUSTOM_POLICY_ID]);
|
||||
|
||||
await user.click(policyTiles[0]);
|
||||
const dialog2 = screen.getByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog2).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Duplicate" }));
|
||||
const metaSecond = latest.customMethodCardMetaById ?? {};
|
||||
const dupIds2 = Object.keys(metaSecond).filter(
|
||||
(id) => id !== CUSTOM_POLICY_ID,
|
||||
);
|
||||
expect(dupIds2).toHaveLength(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("Create flow decision-approaches page", () => {
|
||||
expect(screen.getByText("SELECTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("re-opening a selected approach shows Remove as the modal primary action", async () => {
|
||||
test("re-opening a selected approach shows no modal primary; Remove is in the kebab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -199,11 +199,17 @@ describe("Create flow decision-approaches page", () => {
|
||||
await user.click(card);
|
||||
const dialogAgain = screen.getByRole("dialog");
|
||||
expect(
|
||||
within(dialogAgain).getByRole("button", { name: "Remove" }),
|
||||
).toBeInTheDocument();
|
||||
within(dialogAgain).queryByRole("button", { name: "Remove" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialogAgain).queryByRole("button", { name: "Add Approach" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Remove in the modal deselects the approach", async () => {
|
||||
test("Remove from the kebab deselects the approach", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -221,12 +227,37 @@ describe("Create flow decision-approaches page", () => {
|
||||
|
||||
await user.click(card);
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||
|
||||
expect(card).not.toHaveTextContent("SELECTED");
|
||||
});
|
||||
|
||||
test("when editing a published rule, method modal kebab has no Duplicate", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DecisionApproachesScreenWithState
|
||||
initial={{
|
||||
editingPublishedRuleId: "published-rule-1",
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole("button", {
|
||||
name: /Lazy Consensus: A decision is assumed approved/,
|
||||
});
|
||||
await user.click(card);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Duplicate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("message box checkboxes are interactive", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -166,4 +166,52 @@ describe("applyFinalReviewChipEditPatch", () => {
|
||||
"550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges customMethodCardMetaById when the patch carries methodCardMeta", () => {
|
||||
const state: CreateFlowState = {
|
||||
customMethodCardMetaById: {
|
||||
signal: { label: "Signal", supportText: "Old" },
|
||||
},
|
||||
};
|
||||
const patch: FinalReviewChipEditPatch = {
|
||||
groupKey: "communication",
|
||||
overrideKey: "signal",
|
||||
value: {
|
||||
corePrinciple: "p",
|
||||
logisticsAdmin: "l",
|
||||
codeOfConduct: "c",
|
||||
},
|
||||
methodCardMeta: { label: "Signal (edited)", supportText: "New sub" },
|
||||
};
|
||||
|
||||
const result = applyFinalReviewChipEditPatch(state, patch);
|
||||
|
||||
expect(result.customMethodCardMetaById).toEqual({
|
||||
signal: { label: "Signal (edited)", supportText: "New sub" },
|
||||
});
|
||||
});
|
||||
|
||||
it("updates coreValuesChipsSnapshot label when patch carries chipLabel", () => {
|
||||
const state: CreateFlowState = {
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
coreValueDetailsByChipId: { "1": { meaning: "m", signals: "s" } },
|
||||
};
|
||||
const patch: FinalReviewChipEditPatch = {
|
||||
groupKey: "coreValues",
|
||||
overrideKey: "1",
|
||||
value: { meaning: "m2", signals: "s2" },
|
||||
chipLabel: "A11y renamed",
|
||||
};
|
||||
|
||||
const result = applyFinalReviewChipEditPatch(state, patch);
|
||||
|
||||
expect(result.coreValuesChipsSnapshot).toEqual([
|
||||
{ id: "1", label: "A11y renamed", state: "selected" },
|
||||
]);
|
||||
expect(result.coreValueDetailsByChipId).toEqual({
|
||||
"1": { meaning: "m2", signals: "s2" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
|
||||
});
|
||||
|
||||
it("prefers state.sections when populated (use-without-changes path)", () => {
|
||||
it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
@@ -95,10 +95,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
// Selection ids must be ignored when sections is present — the
|
||||
// "Use without changes" handler resets them for exactly that reason,
|
||||
// but we double-check the helper honors the sections branch first.
|
||||
selectedCommunicationMethodIds: ["in-person-meetings"],
|
||||
selectedCommunicationMethodIds: [],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
@@ -107,6 +104,41 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["in-person-meetings"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Communication", chips: ["In-Person Meetings"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows custom communication chips when sections exist and selections include a UUID", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000099";
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["signal", customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: { label: "Garden IRC", supportText: "x" },
|
||||
},
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
const comm = rows.find((r) => r.name === "Communication");
|
||||
expect(comm?.chips).toEqual(["Signal", "Garden IRC"]);
|
||||
});
|
||||
|
||||
it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
|
||||
@@ -188,6 +188,43 @@ describe("buildPublishPayload — methodSelections", () => {
|
||||
expect(ms?.communication?.[0]?.label).toBe("Custom Comm");
|
||||
});
|
||||
|
||||
it("embeds wizard field blocks in published Communication sections for custom UUID ids", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000099";
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Template row", body: "placeholder" }],
|
||||
},
|
||||
],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: { label: "Wizard title", supportText: "" },
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[customId]: [
|
||||
{
|
||||
kind: "text",
|
||||
id: "b1",
|
||||
blockTitle: "Field A",
|
||||
placeholderText: "User-authored body",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const secs = r.document.sections as Array<{
|
||||
categoryName: string;
|
||||
entries: Array<{ blocks?: Array<{ label: string; body: string }> }>;
|
||||
}>;
|
||||
const comm = secs.find((s) => s.categoryName === "Communication");
|
||||
expect(comm?.entries[0]?.blocks).toEqual([
|
||||
{ label: "Field A", body: "User-authored body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits preset-only sections when a method is selected without an override", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { CreateFlowState } from "../../../app/(app)/create/types";
|
||||
|
||||
const deleteServerDraft = vi.fn();
|
||||
const saveDraftToServer = vi.fn();
|
||||
const updatePublishedRule = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/create/buildPublishPayload", () => ({
|
||||
buildPublishPayload: vi.fn(() => ({
|
||||
ok: true as const,
|
||||
title: "T",
|
||||
summary: "S",
|
||||
document: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/api", () => ({
|
||||
deleteServerDraft: (...args: unknown[]) => deleteServerDraft(...args),
|
||||
saveDraftToServer: (...args: unknown[]) => saveDraftToServer(...args),
|
||||
updatePublishedRule: (...args: unknown[]) => updatePublishedRule(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
writeLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
async function loadExitHook() {
|
||||
return import("../../../app/(app)/create/hooks/useCreateFlowExit");
|
||||
}
|
||||
|
||||
describe("useCreateFlowExit", () => {
|
||||
const router = { push: vi.fn() };
|
||||
const clearState = vi.fn();
|
||||
const user = { id: "u1", email: "a@b.c" };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("NEXT_PUBLIC_ENABLE_BACKEND_SYNC", "true");
|
||||
deleteServerDraft.mockReset();
|
||||
saveDraftToServer.mockReset();
|
||||
updatePublishedRule.mockReset();
|
||||
router.push.mockReset();
|
||||
clearState.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not delete the server draft after updating a published rule (preserves other in-progress work)", async () => {
|
||||
updatePublishedRule.mockResolvedValue({ ok: true as const });
|
||||
|
||||
const { useCreateFlowExit } = await loadExitHook();
|
||||
|
||||
const state: CreateFlowState = {
|
||||
editingPublishedRuleId: "rule-1",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowExit({
|
||||
state,
|
||||
currentStep: "edit-rule",
|
||||
clearState,
|
||||
router,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current({ saveDraft: true });
|
||||
});
|
||||
|
||||
expect(updatePublishedRule).toHaveBeenCalledWith(
|
||||
"rule-1",
|
||||
expect.objectContaining({ title: "T" }),
|
||||
);
|
||||
expect(deleteServerDraft).not.toHaveBeenCalled();
|
||||
expect(router.push).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -22,4 +22,23 @@ describe("mergePresetMethodsWithCustom", () => {
|
||||
supportText: "cx",
|
||||
});
|
||||
});
|
||||
|
||||
it("overlays meta label/supportText onto preset ids for card display", () => {
|
||||
const presets = [
|
||||
{ id: "signal", label: "Signal", supportText: "preset sub" },
|
||||
];
|
||||
const merged = mergePresetMethodsWithCustom(
|
||||
presets,
|
||||
["signal"],
|
||||
{
|
||||
signal: { label: "Renamed", supportText: "user sub" },
|
||||
},
|
||||
);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toEqual({
|
||||
id: "signal",
|
||||
label: "Renamed",
|
||||
supportText: "user sub",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets";
|
||||
import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
describe("methodCardFacetMatchesPresetForId", () => {
|
||||
it("communication: matches fresh preset seed for an unknown id", () => {
|
||||
const p = communicationPresetFor(uuid);
|
||||
expect(communicationMethodFacetMatchesPreset(p, uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it("communication: mismatches when any section differs from preset", () => {
|
||||
const p = communicationPresetFor(uuid);
|
||||
expect(
|
||||
communicationMethodFacetMatchesPreset(
|
||||
{ ...p, corePrinciple: "edited" },
|
||||
uuid,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleHydratePatchIncomplete,
|
||||
isPublishedRuleSelectionMissing,
|
||||
methodSectionsPinsForHydratedSelections,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
@@ -68,6 +69,72 @@ describe("isPublishedRuleSelectionMissing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublishedRuleHydratePatchIncomplete", () => {
|
||||
it("is true when facet ids are present but custom method meta from patch is missing in state", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms method",
|
||||
sections: {
|
||||
corePrinciple: "x",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false);
|
||||
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when patch meta keys exist on state", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms method",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
customMethodCardMetaById: patch.customMethodCardMetaById,
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("alias matches hydrated-selection helper output", () => {
|
||||
const partial: Partial<CreateFlowState> = {
|
||||
@@ -218,6 +285,35 @@ describe("createFlowStateFromPublishedRule", () => {
|
||||
expect(partial.sections).toEqual([]);
|
||||
});
|
||||
|
||||
it("hydrates customMethodCardMetaById for user-authored method ids from methodSelections", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-custom",
|
||||
title: "C",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Custom channel policy",
|
||||
sections: {
|
||||
corePrinciple: "cp",
|
||||
logisticsAdmin: "la",
|
||||
codeOfConduct: "cc",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(partial.selectedCommunicationMethodIds).toEqual([customId]);
|
||||
expect(partial.customMethodCardMetaById?.[customId]).toEqual({
|
||||
label: "Custom channel policy",
|
||||
supportText: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-2",
|
||||
|
||||
@@ -116,4 +116,96 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
||||
doc.sections,
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces stale document.sections method category with full methodSelections (custom rules)", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const doc = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Slack",
|
||||
body: "Only template row; custom card missing from sections.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "Slack principle",
|
||||
logisticsAdmin: "Slack logistics",
|
||||
codeOfConduct: "Slack conduct",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms",
|
||||
sections: {
|
||||
corePrinciple: "Custom principle",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||
const comm = out.find((s) => s.categoryName === "Communication");
|
||||
expect(comm).toBeDefined();
|
||||
expect(comm?.entries.map((e) => e.title)).toEqual([
|
||||
"Slack",
|
||||
"My custom comms",
|
||||
]);
|
||||
expect(
|
||||
comm?.entries.some(
|
||||
(e) => e.title === "My custom comms" && e.blocks?.length,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("includes wizard field blocks when methodSelections preset sections are empty (custom UUID)", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const doc = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Stale template row", body: "ignored after merge" }],
|
||||
},
|
||||
],
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Custom method title",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[customId]: [
|
||||
{
|
||||
kind: "text" as const,
|
||||
id: "f1",
|
||||
blockTitle: "Expectations",
|
||||
placeholderText: "Answer stored only on field blocks.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||
const comm = out.find((s) => s.categoryName === "Communication");
|
||||
expect(comm?.entries.map((e) => e.title)).toEqual(["Custom method title"]);
|
||||
expect(comm?.entries[0]?.blocks).toEqual([
|
||||
{ label: "Expectations", body: "Answer stored only on field blocks." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runCompletedStepExit } from "../../app/(app)/create/utils/runCompletedStepExit";
|
||||
|
||||
describe("runCompletedStepExit", () => {
|
||||
it("clears client draft mirrors and navigates home without implying server DELETE", () => {
|
||||
const clearState = vi.fn();
|
||||
const clearAnonymousCreateFlowStorage = vi.fn();
|
||||
const router = { push: vi.fn() };
|
||||
|
||||
runCompletedStepExit({
|
||||
clearState,
|
||||
clearAnonymousCreateFlowStorage,
|
||||
router,
|
||||
});
|
||||
|
||||
expect(clearState).toHaveBeenCalledTimes(1);
|
||||
expect(clearAnonymousCreateFlowStorage).toHaveBeenCalledTimes(1);
|
||||
expect(router.push).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const meta = { [id]: { label: "L", supportText: "S" } };
|
||||
const blocks: CustomMethodCardFieldBlock[] = [
|
||||
{ kind: "text", id: "b1", blockTitle: "T", placeholderText: "p" },
|
||||
];
|
||||
|
||||
describe("usesWizardFieldBlocksModalBody", () => {
|
||||
it("is false without meta row", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: "signal",
|
||||
meta: {},
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when persisted field blocks exist (wizard card)", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: blocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for proportion-only persisted blocks (read-only modal)", () => {
|
||||
const proportionBlocks: CustomMethodCardFieldBlock[] = [
|
||||
{
|
||||
kind: "proportion",
|
||||
id: "p1",
|
||||
blockTitle: "Share of async",
|
||||
defaultPercent: 40,
|
||||
},
|
||||
];
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: proportionBlocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when persisted blocks exist even if customMethodCardMetaById row is missing", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta: {},
|
||||
fieldBlocksById: { [id]: blocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when meta exists but persisted blocks empty and not editing blocks (preset duplicate stub)", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: [] },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true in read-only modal when custom, blocks empty, and facet matches preset stubs", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: [] },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
customFacetDetailsMatchPreset: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when customizing with empty wizard draft — structured fields stay active", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when customizing with non-empty block draft", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks: blocks,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user