Ask the six optional setup questions after size so each tick can change suggested cards #97

Merged
an.di merged 2 commits from adilallo/fix/CR-294-setup-questions into main 2026-11-06 04:02:47 +00:00
25 changed files with 442 additions and 86 deletions

No files matched your search

@@ -91,6 +91,10 @@ import {
isCommunitySizeStepBlocked, isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked, isCommunityStructureStepBlocked,
} from "./utils/createFlowFooterLabels"; } from "./utils/createFlowFooterLabels";
import {
EMPTY_SETUP_TRAITS,
hasTickedSetupTrait,
} from "../../../lib/create/setupTraits";
import { useAuthModal } from "../../contexts/AuthModalContext"; import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
@@ -984,6 +988,38 @@ function CreateFlowLayoutContent({
: footer.submitEmail} : footer.submitEmail}
</Button> </Button>
</div> </div>
) : currentStep === "setup-questions" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing || !hasTickedSetupTrait(state.setupTraits)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
markCreateFlowInteraction();
goToNextStep();
}}
>
{footer.continue}
</Button>
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
markCreateFlowInteraction();
updateState({ setupTraits: { ...EMPTY_SETUP_TRAITS } });
goToNextStep();
}}
>
{footer.skip}
</Button>
</div>
) : currentStep === "community-upload" && nextStep ? ( ) : currentStep === "community-upload" && nextStep ? (
<Button <Button
buttonType={hasCommunityLogo ? "filled" : "outline"} buttonType={hasCommunityLogo ? "filled" : "outline"}
@@ -30,7 +30,9 @@ export type UseTemplateReviewActionsResult = {
* Customize: apply the template's selections onto state and route to * Customize: apply the template's selections onto state and route to
* `/create/core-values` (if community name is set) or `/create/informational` * `/create/core-values` (if community name is set) or `/create/informational`
* with a `pendingTemplateAction` pin so `/create/review` can later replace * with a `pendingTemplateAction` pin so `/create/review` can later replace
* itself with `/create/core-values`. * itself with `/create/core-values`. A named community skips setup
* questions, so leftover setup answers are dropped and cannot change
* suggested cards.
*/ */
handleCustomize: () => Promise<void>; handleCustomize: () => Promise<void>;
/** /**
@@ -39,8 +41,9 @@ export type UseTemplateReviewActionsResult = {
* Customize) so drilling from final-review via + shows selected cards, drop * Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`. Direct template entry routes to * the Values row from `state.sections`. Direct template entry routes to
* `/create/community-name` with a pin so Next/Back collect only name → * `/create/community-name` with a pin so Next/Back collect only name →
* description → photo. In-flow (`?fromFlow=1`, community steps already done) * description → photo, and drops leftover setup answers along with the
* routes to `/create/confirm-stakeholders`. * other identity fields. In-flow (`?fromFlow=1`, community steps already
* done) routes to `/create/confirm-stakeholders` and keeps those answers.
*/ */
handleUseWithoutChanges: () => Promise<void>; handleUseWithoutChanges: () => Promise<void>;
}; };
@@ -128,7 +131,7 @@ export function useTemplateReviewActions({
}, },
templateReviewBackSlug: undefined, templateReviewBackSlug: undefined,
...(hasCommunityName ...(hasCommunityName
? { pendingTemplateAction: undefined } ? { pendingTemplateAction: undefined, setupTraits: undefined }
: { : {
pendingTemplateAction: { pendingTemplateAction: {
slug: templateReviewSlug, slug: templateReviewSlug,
@@ -256,6 +259,7 @@ export function useTemplateReviewActions({
communityAvatarUrl: undefined, communityAvatarUrl: undefined,
currentStep: undefined, currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined, templateReviewEntryFromCreateFlow: undefined,
setupTraits: undefined,
pendingTemplateAction: { pendingTemplateAction: {
slug: templateReviewSlug, slug: templateReviewSlug,
mode: "useWithoutChanges", mode: "useWithoutChanges",
@@ -27,6 +27,13 @@ const CommunitySizeSelectScreen = dynamic(
})), })),
{ loading: () => null }, { loading: () => null },
); );
const SetupQuestionsScreen = dynamic(
() =>
import("./select/SetupQuestionsScreen").then((m) => ({
default: m.SetupQuestionsScreen,
})),
{ loading: () => null },
);
const CommunityStructureSelectScreen = dynamic( const CommunityStructureSelectScreen = dynamic(
() => () =>
import("./select/CommunityStructureSelectScreen").then((m) => ({ import("./select/CommunityStructureSelectScreen").then((m) => ({
@@ -135,6 +142,8 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
); );
case "community-size": case "community-size":
return <CommunitySizeSelectScreen />; return <CommunitySizeSelectScreen />;
case "setup-questions":
return <SetupQuestionsScreen />;
case "community-upload": case "community-upload":
return <CommunityUploadScreen />; return <CommunityUploadScreen />;
case "community-save": case "community-save":
@@ -0,0 +1,46 @@
"use client";
import CheckboxGroup from "../../../../components/controls/CheckboxGroup";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import {
setupTraitsFromTickedIds,
tickedSetupTraitIds,
} from "../../../../../lib/create/setupTraits";
/**
* Create Community — Figma Flow - Checkboxes (25224:105566).
*/
export function SetupQuestionsScreen() {
const copy = useMessages().create.community.setupQuestions;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
return (
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={copy.title}
description={copy.description}
justification="left"
/>
}
>
<CheckboxGroup
value={tickedSetupTraitIds(state.setupTraits)}
onChange={({ value }) => {
markCreateFlowInteraction();
updateState({ setupTraits: setupTraitsFromTickedIds(value) });
}}
options={copy.questions.map((question) => ({
value: question.id,
label: question.label,
subtext: question.help,
}))}
aria-label={copy.title}
className="flex w-full flex-col gap-[20px] !space-y-0"
/>
</CreateFlowTwoColumnSelectShell>
);
}
+1
View File
@@ -16,6 +16,7 @@ export type CreateFlowStep =
| "informational" | "informational"
| "community-name" | "community-name"
| "community-size" | "community-size"
| "setup-questions"
| "community-context" | "community-context"
| "community-structure" | "community-structure"
| "community-upload" | "community-upload"
@@ -12,7 +12,8 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"1-2", // community-structure "1-2", // community-structure
"1-3", // community-context "1-3", // community-context
"1-4", // community-size "1-4", // community-size
"1-6", // community-upload (1-5 is reserved for setup questions) "1-5", // setup-questions
"1-6", // community-upload
"1-7", // community-save "1-7", // community-save
"1-8", // review "1-8", // review
"2-0", // core-values (same fill as 1-8) "2-0", // core-values (same fill as 1-8)
@@ -57,6 +57,12 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.community.communitySize", messageNamespace: "create.community.communitySize",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
"setup-questions": {
layoutKind: "select",
figmaNodeId: "25224-105566",
messageNamespace: "create.community.setupQuestions",
centeredBodyBelowMd: false,
},
"community-context": { "community-context": {
layoutKind: "text", layoutKind: "text",
figmaNodeId: "25224-106030", figmaNodeId: "25224-106030",
+7 -3
View File
@@ -2,8 +2,9 @@
* Step definitions and helpers for the Create Rule Flow * Step definitions and helpers for the Create Rule Flow
* *
* Single source of truth for step order and navigation helpers. * Single source of truth for step order and navigation helpers.
* Order matches Figma Create Community (frames 1–8) then later stages. * Order matches Figma Create Community then later stages.
* `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap). * `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap).
* `setup-questions` sits after size and before the logo upload.
*/ */
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
@@ -17,6 +18,7 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"community-structure", "community-structure",
"community-context", "community-context",
"community-size", "community-size",
"setup-questions",
"community-upload", "community-upload",
"community-save", "community-save",
"review", "review",
@@ -49,8 +51,8 @@ export type CreateFlowNavigationOptions = {
skipCommunitySave?: boolean; skipCommunitySave?: boolean;
/** /**
* Template **Use without changes**: collect community name, description, and * Template **Use without changes**: collect community name, description, and
* optional photo only. Skips intro, structure, size, save, community review, * optional photo only. Skips intro, structure, size, setup questions, save,
* and the custom-rule authoring segment. * community review, and the custom-rule authoring segment.
*/ */
useWithoutChangesIdentityOnly?: boolean; useWithoutChangesIdentityOnly?: boolean;
}; };
@@ -65,6 +67,7 @@ const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational", "informational",
"community-structure", "community-structure",
"community-size", "community-size",
"setup-questions",
"community-save", "community-save",
"review", "review",
"core-values", "core-values",
@@ -199,6 +202,7 @@ export function shouldOfferCreateFlowSaveAndExit(
*/ */
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [ export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
"community-size", "community-size",
"setup-questions",
"community-structure", "community-structure",
"core-values", "core-values",
"communication-methods", "communication-methods",
@@ -35,9 +35,14 @@ export function CheckboxGroupView({
onOptionChange(option.value, checked); onOptionChange(option.value, checked);
}} }}
/> />
<div className="flex flex-col gap-[4px] flex-1"> <div
className="flex flex-col gap-[4px] flex-1 min-w-0 cursor-pointer"
onClick={() => {
if (!disabled) onOptionChange(option.value, !isChecked);
}}
>
<span <span
className={`text-small-paragraph ${ className={`text-paragraph-m ${
mode === "inverse" mode === "inverse"
? "text-[var(--color-content-invert-primary)]" ? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-primary)]" : "text-[var(--color-content-default-primary)]"
+15 -14
View File
@@ -6,11 +6,11 @@ Product/engineering reference for the **custom** “Create rule” experience: U
## Product stages (Figma) ## Product stages (Figma)
The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard (eight frames). After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today. The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard. After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today.
| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) | | Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) |
| --- | --- | --- | | --- | --- | --- |
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `community-upload` → `community-save` → `review` | | **Create Community** | Intro, naming, structure, context, size, optional setup questions, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `setup-questions` → `community-upload` → `community-save` → `review` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` | | **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` | | **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
@@ -31,17 +31,18 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt
| 3 | Create Community | `community-structure` | `/create/community-structure` | | 3 | Create Community | `community-structure` | `/create/community-structure` |
| 4 | Create Community | `community-context` | `/create/community-context` | | 4 | Create Community | `community-context` | `/create/community-context` |
| 5 | Create Community | `community-size` | `/create/community-size` | | 5 | Create Community | `community-size` | `/create/community-size` |
| 6 | Create Community | `community-upload` | `/create/community-upload` | | 6 | Create Community | `setup-questions` | `/create/setup-questions` |
| 7 | Create Community | `community-save` | `/create/community-save` | | 7 | Create Community | `community-upload` | `/create/community-upload` |
| 8 | Create Community (review frame) | `review` | `/create/review` | | 8 | Create Community | `community-save` | `/create/community-save` |
| 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` | | 9 | Create Community (review frame) | `review` | `/create/review` |
| 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` | | 10 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
| 11 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` | | 11 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
| 12 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` | | 12 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` |
| 13 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` | | 13 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` |
| 14 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` | | 14 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` |
| 15 | Review and complete | `final-review` | `/create/final-review` | | 15 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 16 | Review and complete | `completed` | `/create/completed` | | 16 | Review and complete | `final-review` | `/create/final-review` |
| 17 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)). **Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)).
@@ -87,7 +88,7 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
**Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run. **Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run.
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination. - **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review. - **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, setup questions, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review. - **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`. The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
+35
View File
@@ -0,0 +1,35 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import { TRAIT_KEYS, type TraitKey } from "./deckTypes";
export const EMPTY_SETUP_TRAITS: Record<TraitKey, boolean> = {
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<TraitKey, boolean> {
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;
}
@@ -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"
}
]
}
+1
View File
@@ -1,5 +1,6 @@
{ {
"next": "Next", "next": "Next",
"continue": "Continue",
"skip": "Skip", "skip": "Skip",
"skipForNow": "Skip for now", "skipForNow": "Skip for now",
"saveLater": "Skip for now", "saveLater": "Skip for now",
-60
View File
@@ -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."
}
}
+2
View File
@@ -46,6 +46,7 @@ import createCommunityName from "./create/community/communityName.json";
import createCommunityStructure from "./create/community/communityStructure.json"; import createCommunityStructure from "./create/community/communityStructure.json";
import createCommunityContext from "./create/community/communityContext.json"; import createCommunityContext from "./create/community/communityContext.json";
import createCommunitySize from "./create/community/communitySize.json"; import createCommunitySize from "./create/community/communitySize.json";
import createSetupQuestions from "./create/community/setupQuestions.json";
import createCommunityUpload from "./create/community/communityUpload.json"; import createCommunityUpload from "./create/community/communityUpload.json";
import createCommunitySave from "./create/community/communitySave.json"; import createCommunitySave from "./create/community/communitySave.json";
import createReview from "./create/community/review.json"; import createReview from "./create/community/review.json";
@@ -119,6 +120,7 @@ export default {
communityStructure: createCommunityStructure, communityStructure: createCommunityStructure,
communityContext: createCommunityContext, communityContext: createCommunityContext,
communitySize: createCommunitySize, communitySize: createCommunitySize,
setupQuestions: createSetupQuestions,
communityUpload: createCommunityUpload, communityUpload: createCommunityUpload,
communitySave: createCommunitySave, communitySave: createCommunitySave,
review: createReview, review: createReview,
+90
View File
@@ -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 <SetupQuestionsScreen />;
}
afterEach(() => {
cleanup();
});
describe("Create flow setup-questions page", () => {
it("renders the six questions off by default with a help line under each", () => {
render(<SetupQuestionsScreen />);
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(<SetupQuestionsScreen />);
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(
<ScreenWithState
initial={{ setupTraits: { employer: true, sponsor: false } }}
/>,
);
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/);
});
});
+11
View File
@@ -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", () => { it("returns title and fallback Overview section when no sections", () => {
const r = buildPublishPayload({ title: "Oak Park Commons" }); const r = buildPublishPayload({ title: "Oak Park Commons" });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
@@ -58,6 +58,9 @@ describe("createFlowLayoutTokens", () => {
expect(getCreateFlowContentMaxClass({ step: "community-structure" })).toBe( expect(getCreateFlowContentMaxClass({ step: "community-structure" })).toBe(
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
); );
expect(getCreateFlowContentMaxClass({ step: "setup-questions" })).toBe(
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
);
expect( expect(
getCreateFlowContentMaxClass({ step: "communication-methods" }), getCreateFlowContentMaxClass({ step: "communication-methods" }),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS); ).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
@@ -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( expect(getProportionBarProgressForCreateFlowStep("community-upload")).toBe(
"1-6", "1-6",
); );
+22
View File
@@ -69,6 +69,28 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(false); 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", () => { it("rejects title that is too long", () => {
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) }); const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
expect(r.success).toBe(false); expect(r.success).toBe(false);
+5
View File
@@ -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", () => { it("exposes Skip and Skip for now for optional community steps", () => {
expect(messages.create.footer.skip).toBe("Skip"); expect(messages.create.footer.skip).toBe("Skip");
expect(messages.create.footer.skipForNow).toBe("Skip for now"); expect(messages.create.footer.skipForNow).toBe("Skip for now");
+14 -1
View File
@@ -69,6 +69,14 @@ describe("flowSteps", () => {
expect(getNextStep("community-context")).toBe("community-size"); 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", () => { it("skipCommunitySave bridges upload → review and review → upload", () => {
const opts = { skipCommunitySave: true } as const; const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-upload", opts)).toBe("review"); expect(getNextStep("community-upload", opts)).toBe("review");
@@ -77,7 +85,8 @@ describe("flowSteps", () => {
it("skipCommunitySave does not change steps outside the save segment", () => { it("skipCommunitySave does not change steps outside the save segment", () => {
const opts = { skipCommunitySave: true } as const; 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(getNextStep("review", opts)).toBe("core-values");
expect(getPreviousStep("communication-methods", opts)).toBe("core-values"); expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
}); });
@@ -86,6 +95,8 @@ describe("flowSteps", () => {
const opts = { useWithoutChangesIdentityOnly: true } as const; const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context"); expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload"); 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("community-upload", opts)).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review"); expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review");
expect(getPreviousStep("community-context", opts)).toBe("community-name"); expect(getPreviousStep("community-context", opts)).toBe("community-name");
@@ -199,6 +210,7 @@ describe("flowSteps", () => {
expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("setup-questions")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-upload")).toBe(false); expect(shouldOfferCreateFlowSaveAndExit("community-upload")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-save")).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("community-save")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("review")).toBe(true); expect(shouldOfferCreateFlowSaveAndExit("review")).toBe(true);
@@ -244,6 +256,7 @@ describe("flowSteps", () => {
expect(createFlowStepUsesSelectSplitScroll("community-structure")).toBe( expect(createFlowStepUsesSelectSplitScroll("community-structure")).toBe(
true, true,
); );
expect(createFlowStepUsesSelectSplitScroll("setup-questions")).toBe(true);
expect(createFlowStepUsesSelectSplitScroll("communication-methods")).toBe( expect(createFlowStepUsesSelectSplitScroll("communication-methods")).toBe(
true, true,
); );
+7
View File
@@ -19,6 +19,13 @@ describe("maturity and chip aliases", () => {
expect(answers.mainKind).toBe("workersCoop"); 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", () => { it("loads Enterprise and Larger organization as Established", () => {
const fromId = migrateLegacyCreateFlowState({ const fromId = migrateLegacyCreateFlowState({
selectedMaturityIds: ["4"], selectedMaturityIds: ["4"],
@@ -91,6 +91,7 @@ describe("useTemplateReviewActions", () => {
title: "Neighborhood", title: "Neighborhood",
communityContext: "Stale description", communityContext: "Stale description",
currentStep: "confirm-stakeholders", currentStep: "confirm-stakeholders",
setupTraits: { employer: true, network: false },
}); });
}, },
); );
@@ -104,6 +105,7 @@ describe("useTemplateReviewActions", () => {
expect(applied?.title).toBeUndefined(); expect(applied?.title).toBeUndefined();
expect(applied?.communityContext).toBeUndefined(); expect(applied?.communityContext).toBeUndefined();
expect(applied?.currentStep).toBeUndefined(); expect(applied?.currentStep).toBeUndefined();
expect(applied?.setupTraits).toBeUndefined();
expect(applied?.pendingTemplateAction?.mode).toBe("useWithoutChanges"); expect(applied?.pendingTemplateAction?.mode).toBe("useWithoutChanges");
}); });
@@ -114,6 +116,7 @@ describe("useTemplateReviewActions", () => {
title: "Neighborhood", title: "Neighborhood",
communityContext: "We meet weekly", communityContext: "We meet weekly",
templateReviewEntryFromCreateFlow: true, templateReviewEntryFromCreateFlow: true,
setupTraits: { employer: true },
}); });
}, },
); );
@@ -129,7 +132,38 @@ describe("useTemplateReviewActions", () => {
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders"); expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(applied?.title).toBe("Neighborhood"); expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly"); expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.setupTraits).toEqual({ employer: true });
expect(applied?.pendingTemplateAction).toBeUndefined(); expect(applied?.pendingTemplateAction).toBeUndefined();
expect(applied?.templateReviewBackSlug).toBe("mutual-aid-mondays"); 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");
});
}); });
+40
View File
@@ -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,
});
});
});