Offer the engine's five size bands on the size screen #94

Merged
an.di merged 1 commits from adilallo/fix/CR-222-size-bands into main 2026-11-05 00:13:54 +00:00
11 changed files with 184 additions and 61 deletions

No files matched your search

+10 -2
View File
@@ -85,7 +85,10 @@ import {
methodCardFacetSectionForConfirmStep, methodCardFacetSectionForConfirmStep,
type CustomRuleConfirmFooterStep, type CustomRuleConfirmFooterStep,
} from "./utils/customRuleConfirmFooterSteps"; } from "./utils/customRuleConfirmFooterSteps";
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels"; import {
getDefaultFooterLabel,
isCommunitySizeStepBlocked,
} from "./utils/createFlowFooterLabels";
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";
@@ -985,7 +988,12 @@ function CreateFlowLayoutContent({
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={ disabled={
isPublishing || (isFinalReviewLike && !sessionResolved) isPublishing ||
(isFinalReviewLike && !sessionResolved) ||
isCommunitySizeStepBlocked(
currentStep,
state.selectedCommunitySizeIds,
)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useLayoutEffect } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect"; import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
@@ -18,13 +18,32 @@ function chipRowsFromLabels(
})); }));
} }
function selectedIdsFromOptions(options: ChipOption[]): string[] { /** The first saved id. A count belongs to one band, so later ids are dropped. */
return options function soleSizeId(ids: readonly string[] | undefined): string | null {
.filter((o) => o.state === "selected") for (const id of ids ?? []) {
.map((o) => o.id); if (id.length > 0) return id;
}
return null;
} }
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */ function withSoleSelection(
options: ChipOption[],
selectedId: string | null,
): ChipOption[] {
return options.map((opt) =>
opt.state === "custom"
? opt
: {
...opt,
state:
opt.id === selectedId
? ("selected" as const)
: ("unselected" as const),
},
);
}
/** Create Community — Figma `24715:26346`, chips size M (one question). */
export function CommunitySizeSelectScreen() { export function CommunitySizeSelectScreen() {
const m = useMessages(); const m = useMessages();
const cs = m.create.community.communitySize; const cs = m.create.community.communitySize;
@@ -32,52 +51,40 @@ export function CommunitySizeSelectScreen() {
const [communitySizeOptions, setCommunitySizeOptions] = useState< const [communitySizeOptions, setCommunitySizeOptions] = useState<
ChipOption[] ChipOption[]
>(() => { >(() =>
const base = chipRowsFromLabels(cs.communitySizes); withSoleSelection(
const selected = new Set(state.selectedCommunitySizeIds ?? []); chipRowsFromLabels(cs.communitySizes),
return base.map((opt) => ({ soleSizeId(state.selectedCommunitySizeIds),
...opt,
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
}));
});
useEffect(() => {
const selected = new Set(state.selectedCommunitySizeIds ?? []);
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.state === "custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("selected" as const)
: ("unselected" as const),
},
), ),
); );
useLayoutEffect(() => {
const ids = state.selectedCommunitySizeIds;
if (!ids || ids.length === 0) return;
const first = soleSizeId(ids);
if (ids.length === 1 && ids[0] === first) return;
updateState({ selectedCommunitySizeIds: first ? [first] : [] });
}, [state.selectedCommunitySizeIds, updateState]);
useEffect(() => {
const selectedId = soleSizeId(state.selectedCommunitySizeIds);
setCommunitySizeOptions((prev) => withSoleSelection(prev, selectedId));
}, [state.selectedCommunitySizeIds]); }, [state.selectedCommunitySizeIds]);
const persistSelection = (next: ChipOption[]) => { const persistSelection = (next: ChipOption[]) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setCommunitySizeOptions(next); setCommunitySizeOptions(next);
updateState({ updateState({
selectedCommunitySizeIds: selectedIdsFromOptions(next), selectedCommunitySizeIds: next
.filter((o) => o.state === "selected")
.map((o) => o.id),
}); });
}; };
const handleCommunitySizeClick = (chipId: string) => { const handleCommunitySizeClick = (chipId: string) => {
const next: ChipOption[] = communitySizeOptions.map((opt) => const current = communitySizeOptions.find((opt) => opt.state === "selected");
opt.id === chipId const nextId = current?.id === chipId ? null : chipId;
? { persistSelection(withSoleSelection(communitySizeOptions, nextId));
...opt,
state:
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
);
persistSelection(next);
}; };
const multiSelectBlock = ( const multiSelectBlock = (
+1 -1
View File
@@ -123,7 +123,7 @@ export interface CreateFlowState {
* Picked once and kept on the draft so reload does not change it. * Picked once and kept on the draft so reload does not change it.
*/ */
previewSurfaceColor?: string; previewSurfaceColor?: string;
/** Selected chip ids from `community-size` (MultiSelect). */ /** The size band from `community-size`. One id; 100 is `thirteenToOneHundred`. */
selectedCommunitySizeIds?: string[]; selectedCommunitySizeIds?: string[];
/** Selected chip ids from `community-structure` (organization types). */ /** Selected chip ids from `community-structure` (organization types). */
selectedOrganizationTypeIds?: string[]; selectedOrganizationTypeIds?: string[];
@@ -20,9 +20,17 @@ const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
["confirm-stakeholders", "confirmStakeholders"], ["confirm-stakeholders", "confirmStakeholders"],
["community-context", "confirmDescription"], ["community-context", "confirmDescription"],
["community-structure", "confirmDetails"], ["community-structure", "confirmDetails"],
["community-size", "confirmMembers"], ["community-size", "confirmSize"],
]); ]);
export function isCommunitySizeStepBlocked(
step: CreateFlowStep | null | undefined,
selectedCommunitySizeIds: readonly string[] | undefined,
): boolean {
if (step !== "community-size") return false;
return !(selectedCommunitySizeIds ?? []).some((id) => id.length > 0);
}
/** /**
* Resolve the localized label for the default "next-step" footer button. * Resolve the localized label for the default "next-step" footer button.
* Returns the per-step override when one is registered, otherwise * Returns the per-step override when one is registered, otherwise
@@ -53,7 +53,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
}, },
"community-size": { "community-size": {
layoutKind: "select", layoutKind: "select",
figmaNodeId: "20094-41317", figmaNodeId: "24715-26346",
messageNamespace: "create.community.communitySize", messageNamespace: "create.community.communitySize",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
@@ -76,7 +76,7 @@ These are the only dimensions the recommendation engine scores against.
| Group | Value `id`s (canonical lowercase keys) | Wizard chip labels | `CreateFlowState` field | | Group | Value `id`s (canonical lowercase keys) | Wizard chip labels | `CreateFlowState` field |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `1 member`, `2-5 members`, `6-12 members`, `13-100 members`, `100-100,000 members` | `selectedCommunitySizeIds` | | `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `One member`, `2 to 5`, `6 to 12`, `13 to 100`, `More than 100` | `selectedCommunitySizeIds` |
| `orgType` | `dao`, `forProfit`, `nonprofit`, `openSource`, `mutualAid`, `workersCoop` | `DAO`, `For profit business`, `Nonprofit`, `Open source project`, `Mutual aid`, `Worker's coop` | `selectedOrganizationTypeIds` | | `orgType` | `dao`, `forProfit`, `nonprofit`, `openSource`, `mutualAid`, `workersCoop` | `DAO`, `For profit business`, `Nonprofit`, `Open source project`, `Mutual aid`, `Worker's coop` | `selectedOrganizationTypeIds` |
| `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` | | `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` |
| `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` | | `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` |
@@ -1,13 +1,13 @@
{ {
"header": { "header": {
"title": "How many people will be in your community in the near term?", "title": "How many people will be in your community in the near term?",
"description": "Choose how many people you think will be in your community in the next year or two. Your selection here will determine what governance patterns are recommended later in the process." "description": "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."
}, },
"communitySizes": [ "communitySizes": [
{ "id": "oneMember", "label": "1 member" }, { "id": "oneMember", "label": "One member" },
{ "id": "twoToFive", "label": "2-5 members" }, { "id": "twoToFive", "label": "2 to 5" },
{ "id": "sixToTwelve", "label": "6-12 members" }, { "id": "sixToTwelve", "label": "6 to 12" },
{ "id": "thirteenToOneHundred", "label": "13-100 members" }, { "id": "thirteenToOneHundred", "label": "13 to 100" },
{ "id": "oneHundredToOneHundredK", "label": "100-100,000 members" } { "id": "oneHundredToOneHundredK", "label": "More than 100" }
] ]
} }
+1 -1
View File
@@ -8,7 +8,7 @@
"confirmName": "Confirm name", "confirmName": "Confirm name",
"confirmDetails": "Confirm details", "confirmDetails": "Confirm details",
"confirmDescription": "Confirm description", "confirmDescription": "Confirm description",
"confirmMembers": "Confirm members", "confirmSize": "Confirm size",
"publishCommunityRule": "Publish CommunityRule", "publishCommunityRule": "Publish CommunityRule",
"confirmStakeholders": "Confirm Stakeholders", "confirmStakeholders": "Confirm Stakeholders",
"confirmCoreValues": "Confirm values", "confirmCoreValues": "Confirm values",
+40 -6
View File
@@ -1,8 +1,17 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { CommunitySizeSelectScreen } from "../../app/(app)/create/screens/select/CommunitySizeSelectScreen"; import { CommunitySizeSelectScreen } from "../../app/(app)/create/screens/select/CommunitySizeSelectScreen";
const BANDS = [
"One member",
"2 to 5",
"6 to 12",
"13 to 100",
"More than 100",
] as const;
describe("CommunitySizeSelectScreen", () => { describe("CommunitySizeSelectScreen", () => {
it("renders HeaderLockup title", () => { it("renders HeaderLockup title", () => {
render(<CommunitySizeSelectScreen />); render(<CommunitySizeSelectScreen />);
@@ -13,19 +22,44 @@ describe("CommunitySizeSelectScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders preset size chips", () => { it("asks groups to count people who have a say", () => {
render(<CommunitySizeSelectScreen />); render(<CommunitySizeSelectScreen />);
expect(screen.getByText("1 member")).toBeInTheDocument(); expect(
expect(screen.getByText("2-5 members")).toBeInTheDocument(); screen.getByText(
"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.",
),
).toBeInTheDocument();
}); });
it("gives size chips Select/Deselect accessible names", () => { it("renders the five size bands", () => {
render(<CommunitySizeSelectScreen />); render(<CommunitySizeSelectScreen />);
for (const label of BANDS) {
expect( expect(
screen.getByRole("button", { name: "Select 1 member" }), screen.getByRole("button", { name: `Select ${label}` }),
).toBeInTheDocument();
}
});
it("keeps a single band selected", async () => {
const user = userEvent.setup();
render(<CommunitySizeSelectScreen />);
await user.click(screen.getByRole("button", { name: "Select 13 to 100" }));
expect(
screen.getByRole("button", { name: "Deselect 13 to 100" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Select 2-5 members" }), screen.getByRole("button", { name: "Select More than 100" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Select More than 100" }),
);
expect(
screen.getByRole("button", { name: "Deselect More than 100" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Select 13 to 100" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import communitySize from "../../messages/en/create/community/communitySize.json";
import { SIZE_BANDS } from "../../lib/create/deckTypes";
import { isCommunitySizeStepBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels";
/**
* Inclusive bounds. 100 stays in 13 to 100. More than 100 starts at 101.
* The last band has no upper bound.
*/
const BOUNDS: Record<(typeof SIZE_BANDS)[number], { min: number; max: number | null }> = {
oneMember: { min: 1, max: 1 },
twoToFive: { min: 2, max: 5 },
sixToTwelve: { min: 6, max: 12 },
thirteenToOneHundred: { min: 13, max: 100 },
oneHundredToOneHundredK: { min: 101, max: null },
};
function bandsForCount(count: number): string[] {
return SIZE_BANDS.filter((id) => {
const bound = BOUNDS[id];
return count >= bound.min && (bound.max == null || count <= bound.max);
});
}
describe("community size bands", () => {
it("offers the five engine bands, with exclusive labels", () => {
expect(communitySize.header.description).toBe(
"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.",
);
expect(communitySize.communitySizes).toEqual([
{ id: "oneMember", label: "One member" },
{ id: "twoToFive", label: "2 to 5" },
{ id: "sixToTwelve", label: "6 to 12" },
{ id: "thirteenToOneHundred", label: "13 to 100" },
{ id: "oneHundredToOneHundredK", label: "More than 100" },
]);
expect(communitySize.communitySizes.map((row) => row.id)).toEqual([
...SIZE_BANDS,
]);
});
it("puts each count in exactly one band, with 100 in 13 to 100", () => {
expect(bandsForCount(1)).toEqual(["oneMember"]);
expect(bandsForCount(5)).toEqual(["twoToFive"]);
expect(bandsForCount(6)).toEqual(["sixToTwelve"]);
expect(bandsForCount(12)).toEqual(["sixToTwelve"]);
expect(bandsForCount(13)).toEqual(["thirteenToOneHundred"]);
expect(bandsForCount(100)).toEqual(["thirteenToOneHundred"]);
expect(bandsForCount(101)).toEqual(["oneHundredToOneHundredK"]);
for (let count = 1; count <= 500; count += 1) {
expect(bandsForCount(count)).toHaveLength(1);
}
});
it("blocks continue on the size step until one band is chosen", () => {
expect(isCommunitySizeStepBlocked("community-size", undefined)).toBe(true);
expect(isCommunitySizeStepBlocked("community-size", [])).toBe(true);
expect(isCommunitySizeStepBlocked("community-size", [""])).toBe(true);
expect(
isCommunitySizeStepBlocked("community-size", ["thirteenToOneHundred"]),
).toBe(false);
expect(
isCommunitySizeStepBlocked("community-structure", undefined),
).toBe(false);
});
});
+2 -2
View File
@@ -16,8 +16,8 @@ describe("create footer messages", () => {
); );
}); });
it("exposes confirmMembers for the community-size footer CTA", () => { it("exposes confirmSize for the community-size footer CTA", () => {
expect(messages.create.footer.confirmMembers).toBe("Confirm members"); expect(messages.create.footer.confirmSize).toBe("Confirm size");
}); });
it("exposes publishCommunityRule for the final-review footer CTA", () => { it("exposes publishCommunityRule for the final-review footer CTA", () => {