Add custom intervention modals

This commit is contained in:
adilallo
2026-05-01 22:05:05 -06:00
parent 58d0e33500
commit dee2dd800e
67 changed files with 3480 additions and 197 deletions
+118
View File
@@ -400,6 +400,124 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
expect(latest.communicationMethodDetailsById).toBeUndefined();
});
it("shows consolidated placeholder for user-authored communication chips", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
render(
<FinalReviewWithStateProbe
onState={() => {}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Custom Comm" }),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(/title and description you set/i),
).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).toBeNull();
});
it("shows editable field blocks for user-authored communication chips when configured", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
render(
<FinalReviewWithStateProbe
onState={() => {}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Detail here",
},
],
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Custom Comm" }),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).queryByText(/title and description you set/i),
).not.toBeInTheDocument();
const textarea = within(dialog).getByRole("textbox");
expect(textarea).not.toBeDisabled();
expect(textarea).toHaveValue("Detail here");
});
it("persists field block edits for user-authored communication chips on Save", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Detail here",
},
],
},
}}
/>,
);
fireEvent.click(
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" } });
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",
});
});
});
function FinalReviewEditPublishedWithStateProbe({
+198 -1
View File
@@ -6,7 +6,19 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { useLayoutEffect } from "react";
import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
const CUSTOM_POLICY_ID = "550e8400-e29b-41d4-a716-446655440000";
function CommunicationMethodsScreenWithState({ initial }) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
return <CommunicationMethodsScreen />;
}
afterEach(() => {
cleanup();
@@ -28,6 +40,48 @@ 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 () => {
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: "Add Platform" }));
await user.click(signalCards[0]);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
});
test("Remove in the modal deselects the method", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
});
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Add Platform",
}),
);
expect(signalCards[0]).toHaveTextContent("SELECTED");
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
});
test("renders without error", () => {
render(<CommunicationMethodsScreen />);
@@ -44,12 +98,38 @@ describe("Create flow communication-methods page", () => {
expect(
screen.getByText(/You can select multiple methods for different needs or/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^add$/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
});
test("with a finalized custom policy, inline add link still opens the custom wizard", async () => {
const user = userEvent.setup();
render(
<CommunicationMethodsScreenWithState
initial={{
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Desc" },
},
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Remove policy" }),
).not.toBeInTheDocument();
const addButtons = screen.getAllByRole("button", { name: /^add$/i });
expect(addButtons.length).toBeGreaterThanOrEqual(1);
await user.click(addButtons[0]);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText("What do you call your group's new policy?"),
).toBeInTheDocument();
});
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
@@ -67,5 +147,122 @@ describe("Create flow communication-methods page", () => {
"What method should this community use to communicate with eachother?",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /^add$/i }),
).toBeInTheDocument();
});
test("opening Create modal for custom policy shows saved field blocks", async () => {
const user = userEvent.setup();
const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
methodSectionsPinCommitted: { communication: true },
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(<CommunicationMethodsScreenWithState initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
const textarea = within(dialog).getByRole("textbox");
expect(textarea).not.toBeDisabled();
expect(textarea).toHaveValue("Enter norms here");
});
test("opening Create modal for custom policy shows badge options as chips", async () => {
const user = userEvent.setup();
const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
methodSectionsPinCommitted: { communication: true },
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_POLICY_ID]: [
{
kind: "badges",
id: "b1",
blockTitle: "Choose channels",
options: ["Alpha", "Beta"],
},
],
},
};
render(<CommunicationMethodsScreenWithState initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
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();
});
test("editing custom policy field blocks updates draft state", 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: "Original",
},
],
},
};
render(<Probe initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
const textarea = within(screen.getByRole("dialog")).getByRole("textbox");
await user.clear(textarea);
await user.type(textarea, "Updated norms");
const row = latest.customMethodCardFieldBlocksById?.[CUSTOM_POLICY_ID]?.[0];
expect(row).toMatchObject({
kind: "text",
placeholderText: "Updated norms",
});
});
});
+85 -1
View File
@@ -6,7 +6,19 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { useLayoutEffect } from "react";
import { DecisionApproachesScreen } from "../../app/(app)/create/screens/right-rail/DecisionApproachesScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
const CUSTOM_APPROACH_ID = "550e8400-e29b-41d4-a716-446655440000";
function DecisionApproachesScreenWithState({ initial }) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
return <DecisionApproachesScreen />;
}
afterEach(() => {
cleanup();
@@ -27,7 +39,7 @@ describe("Create flow decision-approaches page", () => {
render(<DecisionApproachesScreen />);
const addControl = screen.getByRole("button", {
name: /^add$/,
name: /^Add$/i,
});
expect(addControl).toBeInTheDocument();
@@ -37,6 +49,31 @@ describe("Create flow decision-approaches page", () => {
expect(description?.textContent).toMatch(/new decision making approaches/);
});
test("with a finalized custom approach, sidebar still shows add (not Remove policy)", () => {
render(
<DecisionApproachesScreenWithState
initial={{
selectedDecisionApproachIds: [CUSTOM_APPROACH_ID],
customMethodCardMetaById: {
[CUSTOM_APPROACH_ID]: {
label: "My approach",
supportText: "Desc",
},
},
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Remove policy" }),
).not.toBeInTheDocument();
const addControl = screen.getByRole("button", { name: /^Add$/i });
expect(addControl).toBeInTheDocument();
const description = addControl.parentElement;
expect(description?.textContent).toMatch(/Select as many as you need/);
expect(description?.textContent).toMatch(/new decision making approaches/);
});
test("renders message box with title and checkboxes", () => {
render(<DecisionApproachesScreen />);
@@ -103,6 +140,7 @@ describe("Create flow decision-approaches page", () => {
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^add$/i })).toHaveLength(2);
});
test("expanded view reveals additional non-recommended approaches", async () => {
@@ -143,6 +181,52 @@ 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 () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
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: "Add Approach",
}),
);
await user.click(card);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
});
test("Remove in the modal deselects the approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add Approach",
}),
);
expect(card).toHaveTextContent("SELECTED");
await user.click(card);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(card).not.toHaveTextContent("SELECTED");
});
test("message box checkboxes are interactive", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
+19
View File
@@ -113,4 +113,23 @@ describe("CardStack Component", () => {
expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1);
});
test("calls onCardSelect when re-clicking an already selected card", () => {
const onCardSelect = vi.fn();
render(
<CardStack
cards={SAMPLE_CARDS}
selectedIds={["1"]}
onCardSelect={onCardSelect}
title="Pick an option"
/>,
);
const cardButtons = screen.getAllByRole("button", {
name: "Option A: Description A",
});
fireEvent.click(cardButtons[0]);
expect(onCardSelect).toHaveBeenCalledWith("1");
expect(screen.queryByRole("button", { name: "Remove policy" })).not.toBeInTheDocument();
});
});
@@ -124,4 +124,46 @@ describe("applyFinalReviewChipEditPatch", () => {
expect(Object.keys(result)).toEqual(["conflictManagementDetailsById"]);
});
it("merges customMethodCardFieldBlocksById when the patch carries field blocks", () => {
const state: CreateFlowState = {
customMethodCardFieldBlocksById: {
other: [
{
kind: "text",
id: "x",
blockTitle: "T",
placeholderText: "keep",
},
],
},
};
const patch: FinalReviewChipEditPatch = {
groupKey: "communication",
overrideKey: "550e8400-e29b-41d4-a716-446655440000",
value: {
corePrinciple: "a",
logisticsAdmin: "b",
codeOfConduct: "c",
},
customMethodCardFieldBlocks: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "edited",
},
],
};
const result = applyFinalReviewChipEditPatch(state, patch);
expect(result.communicationMethodDetailsById).toEqual({
"550e8400-e29b-41d4-a716-446655440000": patch.value,
});
expect(result.customMethodCardFieldBlocksById).toEqual({
other: state.customMethodCardFieldBlocksById?.other,
"550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks,
});
});
});
@@ -59,6 +59,19 @@ describe("buildFinalReviewCategoriesFromState", () => {
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
});
it("resolves user-authored method ids from customMethodCardMetaById", () => {
const customId = "00000000-0000-4000-8000-000000000001";
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", customId],
customMethodCardMetaById: {
[customId]: { label: "Our Slack Ritual", supportText: "desc" },
},
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
const comm = rows.find((r) => r.name === "Communication");
expect(comm?.chips).toEqual(["Signal", "Our Slack Ritual"]);
});
it("dedupes repeated labels from duplicate ids", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "signal"],
+19
View File
@@ -169,6 +169,25 @@ describe("buildPublishPayload — methodSelections", () => {
expect(entries?.[0]?.blocks?.length).toBeGreaterThanOrEqual(1);
});
it("uses customMethodCardMetaById label when preset id is unknown", () => {
const customId = "00000000-0000-4000-8000-000000000002";
const r = buildPublishPayload({
title: "T",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: { label: "Custom Comm", supportText: "More" },
},
});
expect(r.ok).toBe(true);
if (!r.ok) return;
const ms = r.document.methodSelections as
| { communication?: Array<{ id: string; label: string }> }
| undefined;
expect(ms?.communication?.length).toBe(1);
expect(ms?.communication?.[0]?.id).toBe(customId);
expect(ms?.communication?.[0]?.label).toBe("Custom Comm");
});
it("emits preset-only sections when a method is selected without an override", () => {
const r = buildPublishPayload({
title: "T",
+34
View File
@@ -126,6 +126,40 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(true);
});
it("accepts customMethodCardFieldBlocksById", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardFieldBlocksById: {
"card-uuid": [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Optional",
},
{
kind: "badges",
id: "f2",
blockTitle: "Tags",
options: ["a", "b"],
},
{
kind: "upload",
id: "f3",
blockTitle: "Attachment",
fileName: "doc.pdf",
},
{
kind: "proportion",
id: "f4",
blockTitle: "Share",
defaultPercent: 50,
},
],
},
});
expect(r.success).toBe(true);
});
it("accepts templateReviewEntryFromCreateFlow", () => {
const r = createFlowStateSchema.safeParse({
templateReviewEntryFromCreateFlow: true,
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { isCustomMethodCardId } from "../../lib/create/isCustomMethodCardId";
describe("isCustomMethodCardId", () => {
it("is false when meta is missing or id has no entry", () => {
expect(isCustomMethodCardId("signal", undefined)).toBe(false);
expect(isCustomMethodCardId("signal", {})).toBe(false);
});
it("is true when customMethodCardMetaById has the id", () => {
const id = "550e8400-e29b-41d4-a716-446655440000";
expect(
isCustomMethodCardId(id, {
[id]: { label: "L", supportText: "S" },
}),
).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { mergePresetMethodsWithCustom } from "../../lib/create/mergePresetMethodsWithCustom";
describe("mergePresetMethodsWithCustom", () => {
it("appends selected custom ids that have meta after presets", () => {
const presets = [
{ id: "a", label: "A", supportText: "sa" },
{ id: "b", label: "B", supportText: "sb" },
];
const customId = "00000000-0000-4000-8000-000000000099";
const merged = mergePresetMethodsWithCustom(
presets,
["b", customId, "a"],
{
[customId]: { label: "Custom", supportText: "cx" },
},
);
expect(merged.map((m) => m.id)).toEqual(["a", "b", customId]);
expect(merged[2]).toEqual({
id: customId,
label: "Custom",
supportText: "cx",
});
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { moveFacetSelectionIdToFront } from "../../lib/create/methodCardSelectionOrder";
describe("moveFacetSelectionIdToFront", () => {
it("places a new id at index 0", () => {
expect(moveFacetSelectionIdToFront(["a", "b"], "c")).toEqual(["c", "a", "b"]);
});
it("moves an existing id to index 0 without duplicating", () => {
expect(moveFacetSelectionIdToFront(["a", "b", "c"], "b")).toEqual([
"b",
"a",
"c",
]);
});
it("handles empty prior selection", () => {
expect(moveFacetSelectionIdToFront([], "x")).toEqual(["x"]);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { CreateFlowState } from "../../app/(app)/create/types";
import { removeMethodCardFromFacetSelection } from "../../lib/create/removeMethodCardFromFacetSelection";
const CUSTOM_A = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
describe("removeMethodCardFromFacetSelection", () => {
it("returns {} when the card is not in the facet selection", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal"],
};
expect(
removeMethodCardFromFacetSelection(state, "communication", "loomio"),
).toEqual({});
});
it("removes a preset id, its details, and leaves other selections", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "slack"],
communicationMethodDetailsById: {
signal: {} as never,
slack: {} as never,
},
};
const patch = removeMethodCardFromFacetSelection(
state,
"communication",
"signal",
);
expect(patch.selectedCommunicationMethodIds).toEqual(["slack"]);
expect(patch.communicationMethodDetailsById).toEqual({ slack: {} });
});
it("removes a custom card id and clears meta + field blocks", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", CUSTOM_A],
communicationMethodDetailsById: {
signal: {} as never,
[CUSTOM_A]: {} as never,
},
customMethodCardMetaById: {
[CUSTOM_A]: { label: "P", supportText: "D" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_A]: [],
},
};
const patch = removeMethodCardFromFacetSelection(
state,
"communication",
CUSTOM_A,
);
expect(patch.selectedCommunicationMethodIds).toEqual(["signal"]);
expect(patch.communicationMethodDetailsById).toEqual({ signal: {} });
expect(patch.customMethodCardMetaById).toBeUndefined();
expect(patch.customMethodCardFieldBlocksById).toBeUndefined();
});
});
@@ -15,6 +15,19 @@ describe("stripCustomRuleSelectionFields", () => {
selectedConflictManagementIds: ["z"],
methodSectionsPinCommitted: { communication: true },
coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } },
customMethodCardMetaById: {
x: { label: "Custom", supportText: "S" },
},
customMethodCardFieldBlocksById: {
x: [
{
kind: "text",
id: "f1",
blockTitle: "T",
placeholderText: "",
},
],
},
sections: [{ categoryName: "Communication", entries: [] }],
};
const out = stripCustomRuleSelectionFields(prev);
@@ -28,5 +41,7 @@ describe("stripCustomRuleSelectionFields", () => {
expect(out.selectedConflictManagementIds).toBeUndefined();
expect(out.methodSectionsPinCommitted).toBeUndefined();
expect(out.coreValueDetailsByChipId).toBeUndefined();
expect(out.customMethodCardMetaById).toBeUndefined();
expect(out.customMethodCardFieldBlocksById).toBeUndefined();
});
});