Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-10 15:22:46 -06:00
co-authored by Cursor
parent 3f9a8395be
commit 90db213413
31 changed files with 412 additions and 126 deletions
@@ -1,10 +1,13 @@
import { describe, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
import { renderWithProviders as render } from "../utils/test-utils";
import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
import "@testing-library/jest-dom/vitest";
type Props = React.ComponentProps<typeof GovernanceTemplateGrid>;
@@ -13,10 +16,10 @@ const config: ComponentTestSuiteConfig<Props> = {
name: "GovernanceTemplateGrid",
props: {
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2),
onTemplateClick: vi.fn(),
hrefForTemplate: (slug: string) => `/create/review-template/${slug}`,
} as Props,
requiredProps: ["entries", "onTemplateClick"],
primaryRole: "button",
requiredProps: ["entries", "hrefForTemplate"],
primaryRole: "link",
testCases: {
renders: true,
accessibility: true,
@@ -25,4 +28,21 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("GovernanceTemplateGrid", () => {
componentTestSuite<Props>(config);
it("renders each catalog card as a link to template review", () => {
const entries = GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2);
render(
<GovernanceTemplateGrid
entries={entries}
hrefForTemplate={(slug) => `/create/review-template/${slug}`}
/>,
);
for (const entry of entries) {
expect(
screen.getByRole("link", { name: new RegExp(entry.title, "i") }),
).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
}
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
+40 -12
View File
@@ -10,6 +10,18 @@ import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/Com
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { testRouter } from "../mocks/navigation";
function ReviewWithTitle({ title }: { title: string }) {
const { state, updateState } = useCreateFlow();
const seededRef = React.useRef(false);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
updateState({ title });
}, [title, updateState]);
if (state.title !== title) return null;
return <CommunityReviewScreen />;
}
describe("CommunityReviewScreen", () => {
beforeEach(() => {
testRouter.replace.mockReset();
@@ -21,8 +33,24 @@ describe("CommunityReviewScreen", () => {
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
it("renders HeaderLockup with expected title", () => {
it("shows an empty state instead of a baked-in community name", () => {
render(<CommunityReviewScreen />);
expect(
screen.getByRole("heading", { name: "No community in progress yet" }),
).toBeInTheDocument();
expect(screen.queryByText("Mutual Aid Mondays")).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", {
name: "Your community is added - congrats!",
}),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Start creating" }),
).toHaveAttribute("href", "/create/informational");
});
it("renders HeaderLockup with expected title when a community name exists", () => {
render(<ReviewWithTitle title="Garden Club" />);
expect(
screen.getByRole("heading", {
name: "Your community is added - congrats!",
@@ -30,8 +58,8 @@ describe("CommunityReviewScreen", () => {
).toBeInTheDocument();
});
it("renders HeaderLockup with expected description", () => {
render(<CommunityReviewScreen />);
it("renders HeaderLockup with expected description when a community name exists", () => {
render(<ReviewWithTitle title="Garden Club" />);
expect(
screen.getByText(
/In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i,
@@ -39,13 +67,13 @@ describe("CommunityReviewScreen", () => {
).toBeInTheDocument();
});
it("renders Rule with title fallback when no community name is set", () => {
render(<CommunityReviewScreen />);
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
it("renders Rule with the community name from state", () => {
render(<ReviewWithTitle title="Garden Club" />);
expect(screen.getByText("Garden Club")).toBeInTheDocument();
});
it("omits the Rule description when the user has not entered community context", () => {
render(<CommunityReviewScreen />);
render(<ReviewWithTitle title="Garden Club" />);
expect(
screen.queryByText(
/Mutual Aid Monday is a grassroots community in Denver/i,
@@ -53,13 +81,13 @@ describe("CommunityReviewScreen", () => {
).not.toBeInTheDocument();
});
it("renders Rule as a button (card is interactive)", () => {
render(<CommunityReviewScreen />);
it("renders Rule as a button when a community name exists", () => {
render(<ReviewWithTitle title="Garden Club" />);
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThanOrEqual(1);
expect(
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
).toBe(true);
expect(buttons.some((el) => el.textContent?.includes("Garden Club"))).toBe(
true,
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import { CreateFlowTemplatesPageClient } from "../../app/(app)/create/templates/CreateFlowTemplatesPageClient";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
import "@testing-library/jest-dom/vitest";
describe("Create flow templates picker", () => {
test("renders catalog cards as in-flow template review links", () => {
render(
<CreateFlowTemplatesPageClient
initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG}
/>,
);
expect(
screen.getByRole("heading", { name: "Templates", level: 1 }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Consensus/i }),
).toHaveAttribute(
"href",
"/create/review-template/consensus?fromFlow=1",
);
expect(
screen.getByRole("link", { name: /Solidarity Network/i }),
).toHaveAttribute(
"href",
"/create/review-template/solidarity-network?fromFlow=1",
);
});
});
+16 -32
View File
@@ -54,30 +54,18 @@ describe("Templates page (/templates)", () => {
}
});
test("each template card navigates to review flow for its slug", async () => {
const user = userEvent.setup();
test("each template card is a link to review flow for its slug", () => {
render(
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
);
await user.click(
screen.getByRole("button", { name: /Consensus/i }),
);
await waitFor(() => {
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus",
);
});
testRouter.push.mockClear();
await user.click(
screen.getByRole("button", { name: /Solidarity Network/i }),
);
await waitFor(() => {
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/solidarity-network",
);
});
for (const entry of GOVERNANCE_TEMPLATE_CATALOG) {
expect(
screen.getByRole("link", {
name: `Learn more about ${entry.title} governance pattern`,
}),
).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
}
});
test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => {
@@ -88,7 +76,7 @@ describe("Templates page (/templates)", () => {
);
await user.click(
screen.getByRole("button", { name: /Consensus/i }),
screen.getByRole("link", { name: /Consensus/i }),
);
await waitFor(() => {
@@ -96,9 +84,6 @@ describe("Templates page (/templates)", () => {
expect(
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
).toBeNull();
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus",
);
});
});
@@ -113,7 +98,7 @@ describe("Templates page (/templates)", () => {
);
await user.click(
screen.getByRole("button", { name: /Consensus/i }),
screen.getByRole("link", { name: /Consensus/i }),
);
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe(
@@ -124,12 +109,11 @@ describe("Templates page (/templates)", () => {
).toBe(
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
);
// In-flow picks also pass `?fromFlow=1` on the template review URL so
// footer Back on `/create/review-template/…` returns to `/create/review`.
await waitFor(() => {
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus?fromFlow=1",
);
});
expect(
screen.getByRole("link", { name: /Consensus/i }),
).toHaveAttribute(
"href",
"/create/review-template/consensus?fromFlow=1",
);
});
});
+18
View File
@@ -76,6 +76,24 @@ describe("Rule Component", () => {
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("renders as a link when href is set", () => {
const handleClick = vi.fn();
render(
<Rule
{...defaultProps}
href="/create/review-template/consensus"
onClick={handleClick}
/>,
);
const card = screen.getByRole("link", {
name: "Learn more about Test Rule governance pattern",
});
expect(card).toHaveAttribute("href", "/create/review-template/consensus");
fireEvent.click(card, { preventDefault() {} });
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies hover effects correctly", () => {
render(<Rule {...defaultProps} />);
+11 -8
View File
@@ -226,13 +226,14 @@ describe("RuleStack Component", () => {
render(<RuleStack />);
await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div");
const consensusCard = screen.getByRole("link", { name: /Consensus/i });
expect(consensusCard).toHaveAttribute(
"href",
"/create/review-template/consensus",
);
await user.click(consensusCard);
expect(debugSpy).toHaveBeenCalledWith("consensus template clicked");
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus",
);
debugSpy.mockRestore();
});
@@ -251,7 +252,7 @@ describe("RuleStack Component", () => {
render(<RuleStack />);
await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div");
const consensusCard = screen.getByRole("link", { name: /Consensus/i });
await user.click(consensusCard);
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
@@ -319,8 +320,10 @@ describe("RuleStack Component", () => {
render(<RuleStack />);
await waitForRuleStackCards();
const buttons = document.querySelectorAll('[role="button"]');
const templateSurfaces = [...buttons].filter((el) =>
const cards = screen.getAllByRole("link").filter((el) =>
el.getAttribute("href")?.includes("/create/review-template/"),
);
const templateSurfaces = [...cards].filter((el) =>
el.className.includes("--color-surface-invert"),
);
expect(templateSurfaces.length).toBe(homeFeatured.length);
@@ -375,7 +378,7 @@ describe("RuleStack Component", () => {
render(<RuleStack />);
await waitForRuleStackCards();
const doOcracyCard = screen.getByText("Do-ocracy").closest("div");
const doOcracyCard = screen.getByRole("link", { name: /Do-ocracy/i });
await user.click(doOcracyCard);
expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", {
@@ -60,5 +60,11 @@ describe("createFlowLayoutTokens", () => {
isTemplateReview: true,
}),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
expect(
getCreateFlowContentMaxClass({
step: null,
isTemplatesPicker: true,
}),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
});
});
+1
View File
@@ -41,6 +41,7 @@ describe("createFlowPaths (CR-92 §2)", () => {
it("CREATE_ROUTES constants", () => {
expect(CREATE_ROUTES.review).toBe("/create/review");
expect(CREATE_ROUTES.templatesPicker).toBe("/create/templates");
expect(CREATE_ROUTES.completed).toBe("/create/completed");
});
+18 -7
View File
@@ -7,14 +7,12 @@ import {
isValidStep,
getStepIndex,
parseReviewReturnSearchParam,
parseCreateFlowScreenFromPathname,
resolveCreateFlowBackTarget,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
isCreateFlowTemplatesPickerPath,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => {
@@ -158,10 +156,23 @@ describe("flowSteps", () => {
);
});
it("review Create from template uses fromFlow and recommendTemplates together", () => {
it("isCreateFlowTemplatesPickerPath is true only for /create/templates", () => {
expect(isCreateFlowTemplatesPickerPath("/create/templates")).toBe(true);
expect(isCreateFlowTemplatesPickerPath("/create/templates/")).toBe(true);
expect(isCreateFlowTemplatesPickerPath("/create/review-template/consensus")).toBe(
false,
);
expect(isCreateFlowTemplatesPickerPath("/templates")).toBe(false);
expect(isCreateFlowTemplatesPickerPath("/create/review")).toBe(false);
expect(isCreateFlowTemplatesPickerPath(null)).toBe(false);
});
it("parseCreateFlowScreenFromPathname ignores template auxiliary routes", () => {
expect(parseCreateFlowScreenFromPathname("/create/review")).toBe("review");
expect(parseCreateFlowScreenFromPathname("/create/templates")).toBeNull();
expect(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
).toBe("/templates?fromFlow=1&recommendTemplates=1");
parseCreateFlowScreenFromPathname("/create/review-template/consensus"),
).toBeNull();
});
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {