Offer the engine's five size bands as exclusive ranges, so a count of 100 has one home and a group cannot continue without a size.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
821690201e
commit
db278ab67d
11 files changed
+184
-61
No files matched your search
@@ -85,7 +85,10 @@ import {
|
||||
methodCardFacetSectionForConfirmStep,
|
||||
type CustomRuleConfirmFooterStep,
|
||||
} from "./utils/customRuleConfirmFooterSteps";
|
||||
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||
import {
|
||||
getDefaultFooterLabel,
|
||||
isCommunitySizeStepBlocked,
|
||||
} from "./utils/createFlowFooterLabels";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
@@ -985,7 +988,12 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing || (isFinalReviewLike && !sessionResolved)
|
||||
isPublishing ||
|
||||
(isFinalReviewLike && !sessionResolved) ||
|
||||
isCommunitySizeStepBlocked(
|
||||
currentStep,
|
||||
state.selectedCommunitySizeIds,
|
||||
)
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useLayoutEffect } from "react";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
@@ -18,13 +18,32 @@ function chipRowsFromLabels(
|
||||
}));
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "selected")
|
||||
.map((o) => o.id);
|
||||
/** The first saved id. A count belongs to one band, so later ids are dropped. */
|
||||
function soleSizeId(ids: readonly string[] | undefined): string | null {
|
||||
for (const id of ids ?? []) {
|
||||
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() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.community.communitySize;
|
||||
@@ -32,52 +51,40 @@ export function CommunitySizeSelectScreen() {
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
ChipOption[]
|
||||
>(() => {
|
||||
const base = chipRowsFromLabels(cs.communitySizes);
|
||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||
return base.map((opt) => ({
|
||||
...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),
|
||||
},
|
||||
>(() =>
|
||||
withSoleSelection(
|
||||
chipRowsFromLabels(cs.communitySizes),
|
||||
soleSizeId(state.selectedCommunitySizeIds),
|
||||
),
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const persistSelection = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setCommunitySizeOptions(next);
|
||||
updateState({
|
||||
selectedCommunitySizeIds: selectedIdsFromOptions(next),
|
||||
selectedCommunitySizeIds: next
|
||||
.filter((o) => o.state === "selected")
|
||||
.map((o) => o.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommunitySizeClick = (chipId: string) => {
|
||||
const next: ChipOption[] = communitySizeOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
);
|
||||
persistSelection(next);
|
||||
const current = communitySizeOptions.find((opt) => opt.state === "selected");
|
||||
const nextId = current?.id === chipId ? null : chipId;
|
||||
persistSelection(withSoleSelection(communitySizeOptions, nextId));
|
||||
};
|
||||
|
||||
const multiSelectBlock = (
|
||||
|
||||
@@ -123,7 +123,7 @@ export interface CreateFlowState {
|
||||
* Picked once and kept on the draft so reload does not change it.
|
||||
*/
|
||||
previewSurfaceColor?: string;
|
||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||
/** The size band from `community-size`. One id; 100 is `thirteenToOneHundred`. */
|
||||
selectedCommunitySizeIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (organization types). */
|
||||
selectedOrganizationTypeIds?: string[];
|
||||
|
||||
@@ -20,9 +20,17 @@ const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
|
||||
["confirm-stakeholders", "confirmStakeholders"],
|
||||
["community-context", "confirmDescription"],
|
||||
["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.
|
||||
* Returns the per-step override when one is registered, otherwise
|
||||
|
||||
@@ -53,7 +53,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
},
|
||||
"community-size": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-41317",
|
||||
figmaNodeId: "24715-26346",
|
||||
messageNamespace: "create.community.communitySize",
|
||||
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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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` |
|
||||
| `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` |
|
||||
| `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` |
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"header": {
|
||||
"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": [
|
||||
{ "id": "oneMember", "label": "1 member" },
|
||||
{ "id": "twoToFive", "label": "2-5 members" },
|
||||
{ "id": "sixToTwelve", "label": "6-12 members" },
|
||||
{ "id": "thirteenToOneHundred", "label": "13-100 members" },
|
||||
{ "id": "oneHundredToOneHundredK", "label": "100-100,000 members" }
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"confirmName": "Confirm name",
|
||||
"confirmDetails": "Confirm details",
|
||||
"confirmDescription": "Confirm description",
|
||||
"confirmMembers": "Confirm members",
|
||||
"confirmSize": "Confirm size",
|
||||
"publishCommunityRule": "Publish CommunityRule",
|
||||
"confirmStakeholders": "Confirm Stakeholders",
|
||||
"confirmCoreValues": "Confirm values",
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
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", () => {
|
||||
it("renders HeaderLockup title", () => {
|
||||
render(<CommunitySizeSelectScreen />);
|
||||
@@ -13,19 +22,44 @@ describe("CommunitySizeSelectScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders preset size chips", () => {
|
||||
it("asks groups to count people who have a say", () => {
|
||||
render(<CommunitySizeSelectScreen />);
|
||||
expect(screen.getByText("1 member")).toBeInTheDocument();
|
||||
expect(screen.getByText("2-5 members")).toBeInTheDocument();
|
||||
expect(
|
||||
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 />);
|
||||
for (const label of BANDS) {
|
||||
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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,8 @@ describe("create footer messages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes confirmMembers for the community-size footer CTA", () => {
|
||||
expect(messages.create.footer.confirmMembers).toBe("Confirm members");
|
||||
it("exposes confirmSize for the community-size footer CTA", () => {
|
||||
expect(messages.create.footer.confirmSize).toBe("Confirm size");
|
||||
});
|
||||
|
||||
it("exposes publishCommunityRule for the final-review footer CTA", () => {
|
||||
|
||||
Reference in new issue
Block a user