Custom add and create flow polish

This commit is contained in:
adilallo
2026-05-08 20:32:24 -06:00
parent 26bcd61ea3
commit 026a1e6d71
68 changed files with 6208 additions and 527 deletions
@@ -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();
});
});
+193 -28
View File
@@ -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();
});
});