+
{
+ if (!disabled) onOptionChange(option.value, !isChecked);
+ }}
+ >
= {
+ network: false,
+ nonvoters: false,
+ employer: false,
+ sponsor: false,
+ records: false,
+ risky: false,
+};
+
+export function tickedSetupTraitIds(
+ traits: CreateFlowState["setupTraits"],
+): TraitKey[] {
+ if (!traits) return [];
+ return TRAIT_KEYS.filter((key) => traits[key] === true);
+}
+
+export function setupTraitsFromTickedIds(
+ ids: readonly string[],
+): Record {
+ const ticked = new Set(ids);
+ const out = { ...EMPTY_SETUP_TRAITS };
+ for (const key of TRAIT_KEYS) {
+ out[key] = ticked.has(key);
+ }
+ return out;
+}
+
+export function hasTickedSetupTrait(
+ traits: CreateFlowState["setupTraits"],
+): boolean {
+ return tickedSetupTraitIds(traits).length > 0;
+}
diff --git a/messages/en/create/community/setupQuestions.json b/messages/en/create/community/setupQuestions.json
new file mode 100644
index 0000000..2625877
--- /dev/null
+++ b/messages/en/create/community/setupQuestions.json
@@ -0,0 +1,37 @@
+{
+ "_comment": "Create Community setup questions (Figma Flow - Checkboxes 25224:105566). Copy from Figma at build time. Question ids are the trait keys the decks read.",
+ "title": "A few more things about your community",
+ "description": "Tick any that are true. Each one adds or changes a suggested card. We keep your answers with your draft and never publish them.",
+ "questions": [
+ {
+ "id": "network",
+ "label": "Some of our members or partners are organizations",
+ "help": "Such as crews in a network, organizations in a coalition or businesses in a co-op"
+ },
+ {
+ "id": "nonvoters",
+ "label": "Some people who do our work do not vote",
+ "help": "Such as volunteers who are not members, staff who are not owners or contractors"
+ },
+ {
+ "id": "employer",
+ "label": "We pay some people to do our work",
+ "help": "Wages or salary, including worker-owners on payroll. Leave it unticked if everyone is a volunteer or a contractor"
+ },
+ {
+ "id": "sponsor",
+ "label": "Another organization holds our money or legal status",
+ "help": "Such as a fiscal sponsor, a parent nonprofit or a church that takes donations for us"
+ },
+ {
+ "id": "records",
+ "label": "We keep information about the people we serve",
+ "help": "Such as names, addresses, needs, health details or intake notes. A list of our own members does not count"
+ },
+ {
+ "id": "risky",
+ "label": "Our work carries physical risk, or needs a license, permit or inspection",
+ "help": "Such as medical care, building, driving, cooking for the public or caring for children"
+ }
+ ]
+}
diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json
index a7d8da0..eb0c060 100644
--- a/messages/en/create/footer.json
+++ b/messages/en/create/footer.json
@@ -1,5 +1,6 @@
{
"next": "Next",
+ "continue": "Continue",
"skip": "Skip",
"skipForNow": "Skip for now",
"saveLater": "Skip for now",
diff --git a/messages/en/create/setupQuestions.json b/messages/en/create/setupQuestions.json
deleted file mode 100644
index d65f2de..0000000
--- a/messages/en/create/setupQuestions.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "_comment": "Create flow, the setup questions screen after the four setup questions (Figma CR-291, frame 24715:27505) and the setup copy the real-group review changed. Copy agreed 24 Oct. The question ids are the trait keys in _jobs.json.",
- "title": "A few more things about your group",
- "description": "Tick any that are true. Each one adds or changes a suggested card. We keep your answers with your draft and never publish them.",
- "continueButton": "Continue",
- "skipButton": "Skip",
- "questions": [
- {
- "id": "network",
- "label": "Some of our members or partners are groups",
- "help": "Such as crews in a network, organizations in a coalition or businesses in a co-op"
- },
- {
- "id": "nonvoters",
- "label": "Some people who do our work do not vote",
- "help": "Such as volunteers who are not members, staff who are not owners or contractors"
- },
- {
- "id": "employer",
- "label": "We pay some people to do our work",
- "help": "Wages or salary, including worker-owners on payroll. Leave it unticked if everyone is a volunteer or a contractor"
- },
- {
- "id": "sponsor",
- "label": "Another organization holds our money or legal status",
- "help": "Such as a fiscal sponsor, a parent nonprofit or a church that takes donations for us"
- },
- {
- "id": "records",
- "label": "We keep information about the people we serve",
- "help": "Such as names, addresses, needs, health details or intake notes. A list of our own members does not count"
- },
- {
- "id": "risky",
- "label": "Our work carries physical risk, or needs a license, permit or inspection",
- "help": "Such as medical care, building, driving, cooking for the public or caring for children"
- }
- ],
- "sizeHelp": "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup.",
- "kind": {
- "title": "What kind of group are you, mainly?",
- "hint": "Pick one.",
- "othersTitle": "Anything else that fits?",
- "othersHelp": "These help us choose between cards and never add cards on their own."
- },
- "supermajorityShare": {
- "_comment": "Draft copy, waits on Dani’s sign-off. The question itself was agreed 27 Oct, calls 3 and 14. Nothing is preselected. A group that starts from a template answers it at final review.",
- "title": "What share of your members must agree to your biggest decisions?",
- "help": "This is your supermajority share. Your Rule uses it for votes such as changing the Rule, ending a membership and emergencies. Cards that set their own share keep it.",
- "options": {
- "twoThirds": "Two thirds",
- "threeQuarters": "Three quarters",
- "everyone": "Everyone",
- "custom": "Another share"
- },
- "customLabel": "Share of active members who vote, as a percent",
- "warnEveryone": "With everyone, one member can block a change to your Rule or a way out of a deadlock.",
- "warnHalfOrBelow": "At half or below, a change to your Rule could pass without most of the members who vote."
- }
-}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 17b6507..e2af268 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -46,6 +46,7 @@ import createCommunityName from "./create/community/communityName.json";
import createCommunityStructure from "./create/community/communityStructure.json";
import createCommunityContext from "./create/community/communityContext.json";
import createCommunitySize from "./create/community/communitySize.json";
+import createSetupQuestions from "./create/community/setupQuestions.json";
import createCommunityUpload from "./create/community/communityUpload.json";
import createCommunitySave from "./create/community/communitySave.json";
import createReview from "./create/community/review.json";
@@ -119,6 +120,7 @@ export default {
communityStructure: createCommunityStructure,
communityContext: createCommunityContext,
communitySize: createCommunitySize,
+ setupQuestions: createSetupQuestions,
communityUpload: createCommunityUpload,
communitySave: createCommunitySave,
review: createReview,
diff --git a/tests/pages/setup-questions.test.tsx b/tests/pages/setup-questions.test.tsx
new file mode 100644
index 0000000..d4502ee
--- /dev/null
+++ b/tests/pages/setup-questions.test.tsx
@@ -0,0 +1,90 @@
+import "@testing-library/jest-dom/vitest";
+import { useLayoutEffect } from "react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it } from "vitest";
+import { SetupQuestionsScreen } from "../../app/(app)/create/screens/select/SetupQuestionsScreen";
+import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
+import type { CreateFlowState } from "../../app/(app)/create/types";
+import { TRAIT_KEYS } from "../../lib/create/deckTypes";
+import messages from "../../messages/en/index";
+import {
+ cleanup,
+ renderWithProviders as render,
+ screen,
+} from "../utils/test-utils";
+
+const copy = messages.create.community.setupQuestions;
+
+function ScreenWithState({ initial }: { initial: CreateFlowState }) {
+ const { replaceState } = useCreateFlow();
+ useLayoutEffect(() => {
+ replaceState(initial);
+ }, [replaceState, initial]);
+ return ;
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("Create flow setup-questions page", () => {
+ it("renders the six questions off by default with a help line under each", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: copy.title }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(copy.description)).toBeInTheDocument();
+
+ for (const question of copy.questions) {
+ const checkbox = screen.getByRole("checkbox", { name: question.label });
+ expect(checkbox).toHaveAttribute("aria-checked", "false");
+ expect(screen.getByText(question.help)).toBeInTheDocument();
+ }
+ expect(copy.questions).toHaveLength(6);
+ expect(copy.questions.map((question) => question.id)).toEqual([
+ ...TRAIT_KEYS,
+ ]);
+ });
+
+ it("ticks a question from the box or the help line", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("checkbox", { name: copy.questions[2].label }),
+ );
+ expect(
+ screen.getByRole("checkbox", { name: copy.questions[2].label }),
+ ).toHaveAttribute("aria-checked", "true");
+
+ await user.click(screen.getByText(copy.questions[0].help));
+ expect(
+ screen.getByRole("checkbox", { name: copy.questions[0].label }),
+ ).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("restores ticked answers from the draft", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("checkbox", { name: copy.questions[2].label }),
+ ).toHaveAttribute("aria-checked", "true");
+ expect(
+ screen.getByRole("checkbox", { name: copy.questions[3].label }),
+ ).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("does not ask about police, immigration enforcement, or protest", () => {
+ const blob = `${copy.title} ${copy.description} ${copy.questions
+ .map((question) => `${question.label} ${question.help}`)
+ .join(" ")}`.toLowerCase();
+ expect(blob).not.toMatch(/police/);
+ expect(blob).not.toMatch(/immigration/);
+ expect(blob).not.toMatch(/protest/);
+ });
+});
diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts
index 81482b2..9fd2515 100644
--- a/tests/unit/buildPublishPayload.test.ts
+++ b/tests/unit/buildPublishPayload.test.ts
@@ -22,6 +22,17 @@ describe("buildPublishPayload", () => {
});
});
+ it("omits setup answers from the published Rule", () => {
+ const r = buildPublishPayload({
+ title: "Oak Park Commons",
+ setupTraits: { network: true, employer: true, risky: false },
+ });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.document).not.toHaveProperty("setupTraits");
+ expect(JSON.stringify(r)).not.toContain("setupTraits");
+ });
+
it("returns title and fallback Overview section when no sections", () => {
const r = buildPublishPayload({ title: "Oak Park Commons" });
expect(r.ok).toBe(true);
diff --git a/tests/unit/createFlowLayoutTokens.test.ts b/tests/unit/createFlowLayoutTokens.test.ts
index a010719..8f13473 100644
--- a/tests/unit/createFlowLayoutTokens.test.ts
+++ b/tests/unit/createFlowLayoutTokens.test.ts
@@ -58,6 +58,9 @@ describe("createFlowLayoutTokens", () => {
expect(getCreateFlowContentMaxClass({ step: "community-structure" })).toBe(
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
);
+ expect(getCreateFlowContentMaxClass({ step: "setup-questions" })).toBe(
+ CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
+ );
expect(
getCreateFlowContentMaxClass({ step: "communication-methods" }),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
diff --git a/tests/unit/createFlowProportionProgress.test.ts b/tests/unit/createFlowProportionProgress.test.ts
index 3119f1f..1ca1e58 100644
--- a/tests/unit/createFlowProportionProgress.test.ts
+++ b/tests/unit/createFlowProportionProgress.test.ts
@@ -17,7 +17,10 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
);
});
- it("maps the rest of Create Community onto part 1, skipping setup at 1-5", () => {
+ it("maps the rest of Create Community onto part 1, with setup questions at 1-5", () => {
+ expect(getProportionBarProgressForCreateFlowStep("setup-questions")).toBe(
+ "1-5",
+ );
expect(getProportionBarProgressForCreateFlowStep("community-upload")).toBe(
"1-6",
);
diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts
index b946e0b..384089d 100644
--- a/tests/unit/createFlowValidation.test.ts
+++ b/tests/unit/createFlowValidation.test.ts
@@ -69,6 +69,28 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(false);
});
+ it("accepts setup-questions as currentStep and true or false setupTraits", () => {
+ const r = createFlowStateSchema.safeParse({
+ currentStep: "setup-questions",
+ setupTraits: {
+ network: true,
+ nonvoters: false,
+ employer: true,
+ sponsor: false,
+ records: false,
+ risky: true,
+ },
+ });
+ expect(r.success).toBe(true);
+ });
+
+ it("rejects non-boolean setupTraits values", () => {
+ const r = createFlowStateSchema.safeParse({
+ setupTraits: { network: "yes" },
+ });
+ expect(r.success).toBe(false);
+ });
+
it("rejects title that is too long", () => {
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
expect(r.success).toBe(false);
diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts
index db58d95..e53fdc3 100644
--- a/tests/unit/createFooterMessages.test.ts
+++ b/tests/unit/createFooterMessages.test.ts
@@ -26,6 +26,11 @@ describe("create footer messages", () => {
);
});
+ it("exposes Continue and Skip for the setup questions step", () => {
+ expect(messages.create.footer.continue).toBe("Continue");
+ expect(messages.create.footer.skip).toBe("Skip");
+ });
+
it("exposes Skip and Skip for now for optional community steps", () => {
expect(messages.create.footer.skip).toBe("Skip");
expect(messages.create.footer.skipForNow).toBe("Skip for now");
diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts
index ae5ad77..d833026 100644
--- a/tests/unit/flowSteps.test.ts
+++ b/tests/unit/flowSteps.test.ts
@@ -69,6 +69,14 @@ describe("flowSteps", () => {
expect(getNextStep("community-context")).toBe("community-size");
});
+ it("places setup-questions after community-size and before the logo upload", () => {
+ expect(getStepIndex("setup-questions")).toBe(5);
+ expect(getNextStep("community-size")).toBe("setup-questions");
+ expect(getNextStep("setup-questions")).toBe("community-upload");
+ expect(getPreviousStep("community-upload")).toBe("setup-questions");
+ expect(getPreviousStep("setup-questions")).toBe("community-size");
+ });
+
it("skipCommunitySave bridges upload → review and review → upload", () => {
const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-upload", opts)).toBe("review");
@@ -77,7 +85,8 @@ describe("flowSteps", () => {
it("skipCommunitySave does not change steps outside the save segment", () => {
const opts = { skipCommunitySave: true } as const;
- expect(getNextStep("community-size", opts)).toBe("community-upload");
+ expect(getNextStep("community-size", opts)).toBe("setup-questions");
+ expect(getNextStep("setup-questions", opts)).toBe("community-upload");
expect(getNextStep("review", opts)).toBe("core-values");
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
@@ -86,6 +95,8 @@ describe("flowSteps", () => {
const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload");
+ expect(getNextStep("community-size", opts)).toBe("community-upload");
+ expect(getNextStep("setup-questions", opts)).toBe("community-upload");
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review");
expect(getPreviousStep("community-context", opts)).toBe("community-name");
@@ -199,6 +210,7 @@ describe("flowSteps", () => {
expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(false);
+ expect(shouldOfferCreateFlowSaveAndExit("setup-questions")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-upload")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-save")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("review")).toBe(true);
@@ -244,6 +256,7 @@ describe("flowSteps", () => {
expect(createFlowStepUsesSelectSplitScroll("community-structure")).toBe(
true,
);
+ expect(createFlowStepUsesSelectSplitScroll("setup-questions")).toBe(true);
expect(createFlowStepUsesSelectSplitScroll("communication-methods")).toBe(
true,
);
diff --git a/tests/unit/groupAnswers.test.ts b/tests/unit/groupAnswers.test.ts
index 0852991..557dde5 100644
--- a/tests/unit/groupAnswers.test.ts
+++ b/tests/unit/groupAnswers.test.ts
@@ -19,6 +19,13 @@ describe("maturity and chip aliases", () => {
expect(answers.mainKind).toBe("workersCoop");
});
+ it("passes ticked setup answers through as traits", () => {
+ const answers = groupAnswersFromCreateFlowState({
+ setupTraits: { employer: true, records: false },
+ });
+ expect(answers.traits).toEqual({ employer: true, records: false });
+ });
+
it("loads Enterprise and Larger organization as Established", () => {
const fromId = migrateLegacyCreateFlowState({
selectedMaturityIds: ["4"],
diff --git a/tests/unit/hooks/useTemplateReviewActions.test.tsx b/tests/unit/hooks/useTemplateReviewActions.test.tsx
index b183fbb..458f276 100644
--- a/tests/unit/hooks/useTemplateReviewActions.test.tsx
+++ b/tests/unit/hooks/useTemplateReviewActions.test.tsx
@@ -91,6 +91,7 @@ describe("useTemplateReviewActions", () => {
title: "Neighborhood",
communityContext: "Stale description",
currentStep: "confirm-stakeholders",
+ setupTraits: { employer: true, network: false },
});
},
);
@@ -104,6 +105,7 @@ describe("useTemplateReviewActions", () => {
expect(applied?.title).toBeUndefined();
expect(applied?.communityContext).toBeUndefined();
expect(applied?.currentStep).toBeUndefined();
+ expect(applied?.setupTraits).toBeUndefined();
expect(applied?.pendingTemplateAction?.mode).toBe("useWithoutChanges");
});
@@ -114,6 +116,7 @@ describe("useTemplateReviewActions", () => {
title: "Neighborhood",
communityContext: "We meet weekly",
templateReviewEntryFromCreateFlow: true,
+ setupTraits: { employer: true },
});
},
);
@@ -129,7 +132,38 @@ describe("useTemplateReviewActions", () => {
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly");
+ expect(applied?.setupTraits).toEqual({ employer: true });
expect(applied?.pendingTemplateAction).toBeUndefined();
expect(applied?.templateReviewBackSlug).toBe("mutual-aid-mondays");
});
+
+ it("customize with a community name drops leftover setup answers", async () => {
+ const { result } = renderActions({
+ title: "Neighborhood",
+ setupTraits: { employer: true },
+ });
+
+ await act(async () => {
+ await result.current.handleCustomize();
+ });
+
+ expect(router.push).toHaveBeenCalledWith("/create/core-values");
+ expect(updateState).toHaveBeenCalledWith(
+ expect.objectContaining({ setupTraits: undefined }),
+ );
+ });
+
+ it("customize without a community name leaves setup answers for the questionnaire", async () => {
+ const { result } = renderActions({
+ setupTraits: { employer: true },
+ });
+
+ await act(async () => {
+ await result.current.handleCustomize();
+ });
+
+ expect(router.push).toHaveBeenCalledWith("/create/informational");
+ const patch = updateState.mock.calls[0]?.[0] as CreateFlowState;
+ expect(patch).not.toHaveProperty("setupTraits");
+ });
});
diff --git a/tests/unit/setupTraits.test.ts b/tests/unit/setupTraits.test.ts
new file mode 100644
index 0000000..dec1c1f
--- /dev/null
+++ b/tests/unit/setupTraits.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { TRAIT_KEYS } from "../../lib/create/deckTypes";
+import {
+ EMPTY_SETUP_TRAITS,
+ hasTickedSetupTrait,
+ setupTraitsFromTickedIds,
+ tickedSetupTraitIds,
+} from "../../lib/create/setupTraits";
+
+describe("setupTraits", () => {
+ it("starts with every question off", () => {
+ expect(TRAIT_KEYS.every((key) => EMPTY_SETUP_TRAITS[key] === false)).toBe(
+ true,
+ );
+ expect(hasTickedSetupTrait(undefined)).toBe(false);
+ expect(hasTickedSetupTrait(EMPTY_SETUP_TRAITS)).toBe(false);
+ expect(tickedSetupTraitIds(undefined)).toEqual([]);
+ });
+
+ it("round-trips ticked ids as true or false on every trait", () => {
+ const traits = setupTraitsFromTickedIds(["employer", "records"]);
+ expect(traits).toEqual({
+ network: false,
+ nonvoters: false,
+ employer: true,
+ sponsor: false,
+ records: true,
+ risky: false,
+ });
+ expect(tickedSetupTraitIds(traits)).toEqual(["employer", "records"]);
+ expect(hasTickedSetupTrait(traits)).toBe(true);
+ });
+
+ it("ignores ids that are not setup questions", () => {
+ expect(setupTraitsFromTickedIds(["employer", "police"])).toEqual({
+ ...EMPTY_SETUP_TRAITS,
+ employer: true,
+ });
+ });
+});