Finish a Rule, invite members, and let invited people read or copy it #100

Merged
an.di merged 6 commits from adilallo/fix/CR-282-review-finish-invite into main 2026-11-06 16:54:23 +00:00
30 changed files with 539 additions and 186 deletions
Showing only changes of commit 6e2e1915ae - Show all commits

No files matched your search

+15 -4
View File
@@ -90,6 +90,7 @@ import {
isCommunityNameStepBlocked,
isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked,
isFinalReviewContinueBlocked,
} from "./utils/createFlowFooterLabels";
import {
EMPTY_SETUP_TRAITS,
@@ -912,7 +913,7 @@ function CreateFlowLayoutContent({
progressBar={
!isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike &&
currentStep !== "edit-rule" &&
reviewReturnTarget !== "edit-rule"
}
proportionBarProgress={proportionBarProgress}
@@ -1146,14 +1147,22 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={
isPublishing || (isFinalReviewLike && !sessionResolved)
isPublishing ||
(isEditRuleStep && !sessionResolved) ||
(currentStep === "final-review" &&
isFinalReviewContinueBlocked(state))
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (isFinalReviewLike) {
if (isEditRuleStep) {
void handleFinalize();
return;
}
if (currentStep === "final-review") {
if (isFinalReviewContinueBlocked(state)) return;
goToNextStep();
return;
}
markAttempted();
if (
isCommunityStructureStepBlocked(
@@ -1180,7 +1189,9 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
{isFinalReviewLike
{currentStep === "final-review"
? footer.continue
: isFinalReviewLike
? isPublishing
? messages.create.reviewAndComplete.publish
.publishButtonPublishing
@@ -38,7 +38,7 @@ const blurActiveElement = (): void => {
* still needs identity (direct from a template) walks name / description /
* photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* identity and land on `final-review`; Back from that step returns
* to the template instead of `conflict-management`.
*
* Template review footer Back uses {@link buildTemplateReviewHref}’s
@@ -43,7 +43,7 @@ export type UseTemplateReviewActionsResult = {
* `/create/community-name` with a pin so Next/Back collect only name →
* description → photo, and drops leftover setup answers along with the
* other identity fields. In-flow (`?fromFlow=1`, community steps already
* done) routes to `/create/confirm-stakeholders` and keeps those answers.
* done) routes to `/create/final-review` and keeps those answers.
*/
handleUseWithoutChanges: () => Promise<void>;
};
@@ -268,7 +268,7 @@ export function useTemplateReviewActions({
});
router.push(
fromCreateWizard
? createFlowStepPath("confirm-stakeholders")
? createFlowStepPath("final-review")
: createFlowStepPath("community-name"),
);
}, [
@@ -14,8 +14,11 @@ import {
} from "../../components/CreateFlowLockupCardStepShell";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { communityInitialsFromTitle } from "../../../../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../../../../lib/create/finalReviewChipWarnings";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type {
TemplateChipDetail,
@@ -32,10 +35,6 @@ import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisp
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import {
persistDraftPreviewSurfaceColor,
@@ -51,53 +50,17 @@ function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
return joinsWithoutAYes && conflict.includes("binding-arbitration");
}
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
* management). We reuse that ordering for the state-derived rows so the
* Rule layout stays stable across customize / use-without-changes /
* plain-custom flows, and fall back to the demo chips when state resolves
* to nothing selected.
*/
function readFallbackCategoryRows(
categories: readonly { name: string; chips: readonly string[] }[],
): {
names: {
values: string;
communication: string;
membership: string;
decisions: string;
conflict: string;
};
rows: FinalReviewCategoryRowDetailed[];
} {
function categoryNamesFromMessages(
categories: readonly { name: string }[],
): FinalReviewCategoryNames {
const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : "";
return {
names: {
values: get(0),
communication: get(1),
membership: get(2),
decisions: get(3),
conflict: get(4),
},
rows: categories.map((c) => ({
name: c.name,
groupKey: null,
entries: [...c.chips].map((label) => ({
label,
groupKey: null,
overrideKey: null,
})),
})),
};
}
@@ -145,28 +108,32 @@ export function FinalReviewScreen({
);
const { categories: finalReviewCategories } = useMemo(() => {
const { names, rows: fallbackRows } = readFallbackCategoryRows(
m.create.reviewAndComplete.finalReview.categories,
);
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
const copy = m.create.reviewAndComplete.finalReview;
const names = categoryNamesFromMessages(copy.categories);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
derived.length > 0 ? derived : fallbackRows;
const usingFallbackRows = derived.length === 0;
buildFinalReviewCategoryRowsDetailed(state, names);
const lookup = new Map<
string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>();
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
const effectiveGroupKey: TemplateFacetGroupKey | null =
row.groupKey ??
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
? FACET_FALLBACK_ORDER[rowIndex]
: null);
const cats: Category[] = rowsToRender.map((row) => {
const effectiveGroupKey: TemplateFacetGroupKey | null = row.groupKey;
const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
const isEmpty = row.entries.length === 0;
const flag =
isEmpty && effectiveGroupKey === "coreValues"
? copy.flags.noValues
: isEmpty && effectiveGroupKey != null
? copy.flags.noCard
: undefined;
const addButtonText = isEmpty
? effectiveGroupKey === "coreValues"
? copy.addValue
: copy.addCard
: "";
const chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`;
@@ -190,12 +157,19 @@ export function FinalReviewScreen({
id: chipId,
label: entry.label,
state: "unselected" as const,
hasWarning: finalReviewChipHasWarning(
state,
entry.groupKey,
entry.overrideKey,
),
};
});
return {
name: row.name,
chipOptions,
addButton: effectiveGroupKey != null,
addButtonText,
flag,
onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId);
if (!hit) return;
@@ -219,7 +193,7 @@ export function FinalReviewScreen({
});
return { categories: cats };
}, [
m.create.reviewAndComplete.finalReview.categories,
m.create.reviewAndComplete.finalReview,
state,
markCreateFlowInteraction,
goToStep,
@@ -249,8 +223,7 @@ export function FinalReviewScreen({
const rawTitleForModal =
typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const descriptionEmptyHint = t("communityContextEditModal.emptyHint");
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
const storedFallback =
@@ -305,34 +278,21 @@ export function FinalReviewScreen({
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
onTitleClick={
variant === "editPublished"
? () => setTitleModalOpen(true)
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={
variant === "editPublished"
? () => setCommunityContextModalOpen(true)
: undefined
}
onTitleClick={() => setTitleModalOpen(true)}
titleEditAriaLabel={t("titleEditModal.ariaEditTitle")}
onDescriptionClick={() => setCommunityContextModalOpen(true)}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={
variant === "editPublished"
? t("communityContextEditModal.ariaEditDescription")
: undefined
}
descriptionEditAriaLabel={t("communityContextEditModal.ariaEditDescription")}
size={mdUp ? "L" : "M"}
expanded={true}
descriptionAtBottom
backgroundColor="bg-transparent"
surfaceColor={surfaceColor}
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))}
logoUrl={avatarUrl}
logoAlt={ruleCardTitle}
communityInitials={
avatarUrl ? undefined : communityInitialsFromTitle(ruleCardTitle)
}
categories={finalReviewCategories}
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
onClick={() => {}}
@@ -352,8 +312,6 @@ export function FinalReviewScreen({
onClose={() => setActiveReadOnlyDetail(null)}
detail={activeReadOnlyDetail}
/>
{variant === "editPublished" ? (
<>
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
@@ -372,8 +330,6 @@ export function FinalReviewScreen({
updateState({ communityContext: value, summary: value });
}}
/>
</>
) : null}
</CreateFlowLockupCardStepShell>
);
}
@@ -1,5 +1,10 @@
import type footerMessages from "../../../../messages/en/create/footer.json";
import type { CreateFlowStep } from "../types";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
} from "../../../../lib/create/buildFinalReviewCategories";
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
import type { CreateFlowState, CreateFlowStep } from "../types";
type FooterMessages = typeof footerMessages;
@@ -9,9 +14,10 @@ type FooterMessages = typeof footerMessages;
* fires `goToNextStep` for steps without a bespoke footer). Steps absent
* from this map fall back to `footer.next`.
*
* `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `publishCommunityRule`).
* `final-review` uses Continue (gated by {@link isFinalReviewContinueBlocked}).
* `edit-rule` is handled separately by the caller because its label also
* depends on the in-flight publish flag (`publishButtonPublishing` vs
* `publishCommunityRule`).
*/
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep,
@@ -53,10 +59,42 @@ export function isCommunitySizeStepBlocked(
return !hasNonEmptyId(selectedCommunitySizeIds);
}
const FINAL_REVIEW_BLOCK_NAMES: FinalReviewCategoryNames = {
values: "values",
communication: "communication",
membership: "membership",
decisions: "decisions",
conflict: "conflict",
};
const FINAL_REVIEW_REQUIRED_GROUPS: readonly TemplateFacetGroupKey[] = [
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* Continue on final review waits for a community name and at least one card
* on each of the four questions. Skipped values do not block.
*/
export function isFinalReviewContinueBlocked(state: CreateFlowState): boolean {
const title = typeof state.title === "string" ? state.title.trim() : "";
if (title.length === 0) return true;
const rows = buildFinalReviewCategoryRowsDetailed(
state,
FINAL_REVIEW_BLOCK_NAMES,
);
return FINAL_REVIEW_REQUIRED_GROUPS.some((groupKey) => {
const row = rows.find((candidate) => candidate.groupKey === groupKey);
return !row || row.entries.length === 0;
});
}
/**
* Resolve the localized label for the default "next-step" footer button.
* Returns the per-step override when one is registered, otherwise
* `footer.next`. Caller still owns the `final-review` special case.
* `footer.next`. Caller still owns the `final-review` / `edit-rule` special case.
*/
export function getDefaultFooterLabel(
step: CreateFlowStep | null | undefined,
@@ -21,8 +21,8 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // final-review
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // completed
] as const;
@@ -123,18 +123,18 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-213155",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"edit-rule": {
layoutKind: "review",
figmaNodeId: "20907-212767",
+5 -5
View File
@@ -27,8 +27,8 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"membership-methods",
"decision-approaches",
"conflict-management",
"confirm-stakeholders",
"final-review",
"confirm-stakeholders",
"completed",
] as const;
@@ -61,7 +61,7 @@ export type CreateFlowNavigationOptions = {
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
* `community-upload`, then `final-review`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
@@ -131,9 +131,9 @@ export function getPreviousStep(
* Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* `final-review` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* from `final-review` returns to template review instead of
* `conflict-management`.
*/
export type CreateFlowBackTarget =
@@ -157,7 +157,7 @@ export function resolveCreateFlowBackTarget(
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
currentStep === "final-review" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
+4
View File
@@ -6,6 +6,10 @@ export interface Category {
chipOptions: ChipOption[];
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
addButton?: boolean;
/** Label on the add control. Empty string keeps the icon-only circle. */
addButtonText?: string;
/** Figma Has Flag: short line after the add chip in the same row. */
flag?: string;
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (
+2 -1
View File
@@ -472,7 +472,8 @@ export function RuleView({
addButton={
!hideCategoryAddButton && category.addButton !== false
}
addButtonText="" // Empty text for icon-only circular button
addButtonText={category.addButtonText ?? ""}
flag={category.flag}
className="w-full"
/>
))}
@@ -21,6 +21,7 @@ const ChipContainer = memo<ChipProps>(
onRemove,
onCheck,
onClose,
hasWarning = false,
ariaLabel,
}) => {
const t = useTranslation("controlsChrome");
@@ -89,11 +90,19 @@ const ChipContainer = memo<ChipProps>(
onRemove={onRemove}
onCheck={handleCheck}
onClose={handleClose}
hasWarning={hasWarning}
inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel ?? (isCustom ? undefined : label)}
ariaLabel={
ariaLabel ??
(isCustom
? undefined
: hasWarning
? t("chipMissingWords").replace("{label}", label)
: label)
}
confirmAriaLabel={t("chipConfirm")}
typeToAddPlaceholder={t("chipTypeToAdd")}
closeAriaLabel={t("chipClose")}
@@ -48,6 +48,11 @@ export interface ChipProps {
* Optional callback when custom chip is closed/removed.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Figma Has Warning: show a warning icon in place of the selected check.
* Used on final review when a card or value is missing its words.
*/
hasWarning?: boolean;
ariaLabel?: string;
}
@@ -62,6 +67,7 @@ export interface ChipViewProps {
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
hasWarning?: boolean;
inputValue?: string;
onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
+9 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, type CSSProperties } from "react";
import Icon from "../../asset/icon/Icon";
import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle(
@@ -87,6 +88,7 @@ function ChipView({
onRemove,
onCheck,
onClose,
hasWarning = false,
inputValue,
onInputChange,
onInputKeyDown,
@@ -280,7 +282,13 @@ function ChipView({
onClick={handleClick}
{...sharedA11y}
>
{isSelected ? (
{hasWarning ? (
<Icon
name="warning"
size={isSmall ? 12 : 16}
className="shrink-0 text-current"
/>
) : isSelected ? (
<svg
aria-hidden
viewBox="0 0 12 12"
@@ -20,6 +20,7 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick,
addButton: addButtonProp = true,
addButtonText,
flag,
formHeader = true,
helperText,
errorMessage,
@@ -45,11 +46,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick={onAddClick}
addButton={addButtonProp}
addButtonText={addButtonText}
flag={flag}
addButtonAriaLabel={
addButtonText || t("multiSelectAddFallback")
}
chipSelectAriaLabel={t("chipSelect")}
chipDeselectAriaLabel={t("chipDeselect")}
chipMissingWordsAriaLabel={t("chipMissingWords")}
formHeader={formHeader}
helperText={helperText}
errorMessage={errorMessage}
@@ -7,6 +7,8 @@ export interface ChipOption {
id: string;
label: string;
state?: ChipStateValue;
/** Figma Has Warning on Control / Chip. */
hasWarning?: boolean;
}
export type MultiSelectSizeValue = "s" | "m";
@@ -49,6 +51,11 @@ export interface MultiSelectProps {
* Text for the add button
*/
addButtonText?: string;
/**
* Figma Has Flag: short line after the add chip in the same row, with no
* fill and no extra height.
*/
flag?: string;
/**
* Whether to show form header (label and help icon) above multi-select (Figma prop).
* @default true
@@ -93,7 +100,9 @@ export interface MultiSelectViewProps {
onAddClick?: () => void;
addButton: boolean;
addButtonText?: string;
flag?: string;
addButtonAriaLabel: string;
chipMissingWordsAriaLabel: string;
chipSelectAriaLabel: string;
chipDeselectAriaLabel: string;
formHeader: boolean;
@@ -2,6 +2,7 @@
import { memo } from "react";
import Chip from "../Chip";
import Icon from "../../asset/icon/Icon";
import InputLabel from "../../type/InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
@@ -15,9 +16,11 @@ function MultiSelectView({
onAddClick,
addButton,
addButtonText,
flag,
addButtonAriaLabel,
chipSelectAriaLabel,
chipDeselectAriaLabel,
chipMissingWordsAriaLabel,
formHeader = true,
helperText: helperTextProp,
errorMessage,
@@ -94,9 +97,15 @@ function MultiSelectView({
palette={palette}
size={chipSize}
disabled={chipDisabled}
hasWarning={option.hasWarning}
ariaLabel={
isCustom || !option.label
? undefined
: option.hasWarning
? chipMissingWordsAriaLabel.replace(
"{label}",
option.label,
)
: (isSelected
? chipDeselectAriaLabel
: chipSelectAriaLabel
@@ -180,6 +189,22 @@ function MultiSelectView({
)}
</button>
)}
{flag ? (
<span
className={`inline-flex items-center gap-[var(--space-100)] text-x-small-paragraph ${
isInverse
? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-default-primary)]"
}`}
>
<Icon
name="warning"
size={12}
className="shrink-0 text-current"
/>
{flag}
</span>
) : null}
</div>
{errorMessage ? (
<p
+6 -6
View File
@@ -12,7 +12,7 @@ The Figma **Create Community** sequence is the **source of truth** for the first
| --- | --- | --- |
| **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` |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
| **Review and complete** | Final card, invite members, finish, success. | `final-review` → `confirm-stakeholders` → `completed` |
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma still names eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed**. Question-and-options steps (**select**, **card**, **right-rail**) share [`CreateFlowTwoColumnSelectShell`](../app/(app)/create/components/CreateFlowTwoColumnSelectShell.tsx) and [`Selection`](../app/components/cards/Selection/) size variants (`s` chip, `m` tile, `l` row). `app/(app)/create/screens/` still mirrors the Figma kind names.
@@ -40,8 +40,8 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt
| 12 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` |
| 13 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` |
| 14 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` |
| 15 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 16 | Review and complete | `final-review` | `/create/final-review` |
| 15 | Review and complete | `final-review` | `/create/final-review` |
| 16 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 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)).
@@ -83,13 +83,13 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
**Exit on template preview.** Direct catalog / marketing entry (`/create/review-template/[slug]` without `?fromFlow=1`) has nothing to save — top-nav **Exit** clears client state and goes home, with no save-progress modal and no leave confirm. In-flow (`?fromFlow=1`) still has community progress, so Exit keeps the usual save / leave-confirm path.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. Continue on final review opens **Invite your members**. Finishing there runs **`handleFinalize → publishRule`**. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**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.
- **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 (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/final-review`. 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/final-review`. 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`.
+54 -5
View File
@@ -50,6 +50,56 @@ export type FinalReviewCategoryNames = {
conflict: string;
};
const CANONICAL_FACET_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
function nameForGroup(
groupKey: TemplateFacetGroupKey,
names: FinalReviewCategoryNames,
): string {
switch (groupKey) {
case "coreValues":
return names.values;
case "communication":
return names.communication;
case "membership":
return names.membership;
case "decisionApproaches":
return names.decisions;
case "conflictManagement":
return names.conflict;
}
}
/** Always five section rows, in values-first order, including empty ones. */
export function ensureCanonicalFinalReviewRows(
rows: readonly FinalReviewCategoryRowDetailed[],
names: FinalReviewCategoryNames,
): FinalReviewCategoryRowDetailed[] {
const byGroup = new Map<TemplateFacetGroupKey, FinalReviewCategoryRowDetailed>();
for (const row of rows) {
if (row.groupKey == null) continue;
byGroup.set(row.groupKey, {
...row,
name: nameForGroup(row.groupKey, names),
});
}
return CANONICAL_FACET_ORDER.map((groupKey) => {
const existing = byGroup.get(groupKey);
if (existing) return existing;
return {
name: nameForGroup(groupKey, names),
groupKey,
entries: [],
};
});
}
type MethodPreset = { id: string; label: string };
/**
@@ -230,7 +280,7 @@ export function buildFinalReviewCategoryRowsDetailed(
if (entries.length === 0) continue;
rows.push({ name: s.categoryName, groupKey, entries });
}
return rows;
return ensureCanonicalFinalReviewRows(rows, names);
}
const rows: FinalReviewCategoryRowDetailed[] = [
@@ -276,7 +326,7 @@ export function buildFinalReviewCategoryRowsDetailed(
),
},
];
return rows.filter((r) => r.entries.length > 0);
return ensureCanonicalFinalReviewRows(rows, names);
}
/**
@@ -293,9 +343,8 @@ export function buildFinalReviewCategoryRowsDetailed(
* against the curated message `methods[]` list to get the display labels,
* matching what the user saw as chips in-flow.
*
* Empty categories are filtered out so the review card doesn't render headings
* with no chips. If nothing in state resolves to any chip, the caller should
* fall back to the demo categories shipped in `finalReview.json`.
* Empty categories stay on the card so add chips and flags can show. The five
* named rows always render, in values-first order.
*/
export function buildFinalReviewCategoriesFromState(
state: CreateFlowState,
+7
View File
@@ -0,0 +1,7 @@
/** One or two letters from a community name for the Rule card mark. */
export function communityInitialsFromTitle(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
}
+90
View File
@@ -0,0 +1,90 @@
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
function missingText(...values: readonly (string | undefined)[]): boolean {
return values.some((value) => (value ?? "").trim().length === 0);
}
function communicationMissingWords(
saved: CommunicationMethodDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.logisticsAdmin,
saved.codeOfConduct,
);
}
function membershipMissingWords(
saved: MembershipMethodDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.eligibility,
saved.joiningProcess,
saved.expectations,
);
}
function decisionMissingWords(
saved: DecisionApproachDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.stepByStepInstructions,
saved.objectionsDeadlocks,
);
}
function conflictMissingWords(
saved: ConflictManagementDetailEntry | undefined,
): boolean {
if (!saved) return true;
return missingText(
saved.corePrinciple,
saved.processProtocol,
saved.restorationFallbacks,
);
}
/**
* A review chip shows a warning when its value is missing meaning or signals,
* or a card is missing its words.
*/
export function finalReviewChipHasWarning(
state: CreateFlowState,
groupKey: TemplateFacetGroupKey | null,
overrideKey: string | null,
): boolean {
if (!groupKey || !overrideKey) return true;
switch (groupKey) {
case "coreValues": {
const saved = state.coreValueDetailsByChipId?.[overrideKey];
return missingText(saved?.meaning, saved?.signals);
}
case "communication":
return communicationMissingWords(
state.communicationMethodDetailsById?.[overrideKey],
);
case "membership":
return membershipMissingWords(
state.membershipMethodDetailsById?.[overrideKey],
);
case "decisionApproaches":
return decisionMissingWords(
state.decisionApproachDetailsById?.[overrideKey],
);
case "conflictManagement":
return conflictMissingWords(
state.conflictManagementDetailsById?.[overrideKey],
);
}
}
@@ -7,6 +7,7 @@
"chipClose": "Close",
"chipSelect": "Select {label}",
"chipDeselect": "Deselect {label}",
"chipMissingWords": "{label}, missing words",
"selectPlaceholder": "Choose an option",
"selectAriaLabel": "Select an option",
"multiSelectAddDefault": "Add organization type",
@@ -1,9 +1,15 @@
{
"title": "Review your CommunityRule",
"description": "Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again.",
"title": "Review your Rule",
"description": "Here's what other people will see.",
"editPublishedTitle": "Edit your CommunityRule",
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.",
"ruleCardTitleFallback": "Your community",
"addValue": "Add value",
"addCard": "Add card",
"flags": {
"noValues": "No values yet. You can add them any time.",
"noCard": "No card yet. Add one before you finish."
},
"chipEditModal": {
"saveButton": "Save",
"readOnlyCloseButton": "Close",
@@ -35,7 +41,7 @@
},
{
"name": "Membership",
"chips": ["Open Admission"]
"chips": ["Open admission"]
},
{
"name": "Decision-making",
+10
View File
@@ -64,4 +64,14 @@ describe("Chip", () => {
expect(chip).toHaveAttribute("aria-pressed", "true");
expect(chip.querySelector("svg[aria-hidden]")).not.toBeNull();
});
it("shows a warning icon and names the missing words", () => {
render(
<Chip label="Signal" state="unselected" hasWarning />,
);
const chip = screen.getByRole("button", {
name: "Signal, missing words",
});
expect(chip.querySelector("svg")).not.toBeNull();
});
});
+23 -15
View File
@@ -14,7 +14,9 @@ import { clearCoreValueDetailsLocalStorage } from "../../app/(app)/create/utils/
import type { CreateFlowState } from "../../app/(app)/create/types";
function catalogChipName(label: string) {
return new RegExp(`^(Select|Deselect) ${label}$`);
return new RegExp(
`^((Select|Deselect) ${label}|${label}, missing words)$`,
);
}
async function confirmDiscardCustomizeEdits() {
@@ -75,7 +77,7 @@ describe("FinalReviewScreen", () => {
render(<FinalReviewScreen />);
expect(
screen.getByRole("heading", {
name: "Review your CommunityRule",
name: "Review your Rule",
}),
).toBeInTheDocument();
});
@@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => {
it("renders lockup description", () => {
render(<FinalReviewScreen />);
expect(
screen.getByText(
/Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again./i,
),
screen.getByText("Here's what other people will see."),
).toBeInTheDocument();
});
@@ -182,11 +182,19 @@ describe("FinalReviewScreen", () => {
expect(screen.getByText("Conflict management")).toBeInTheDocument();
});
it("renders category chips", () => {
it("shows add chips and flags when no cards or values are picked", () => {
render(<FinalReviewScreen />);
expect(screen.getByText("Consciousness")).toBeInTheDocument();
expect(screen.getByText("Signal")).toBeInTheDocument();
expect(screen.getByText("Open Admission")).toBeInTheDocument();
expect(
screen.getByText("No values yet. You can add them any time."),
).toBeInTheDocument();
expect(
screen.getAllByText("No card yet. Add one before you finish.").length,
).toBe(4);
expect(
screen.getByRole("button", { name: "Add value" }),
).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Add card" })).toHaveLength(4);
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
});
});
@@ -225,10 +233,10 @@ describe("FinalReviewScreen — prefilled selections", () => {
expect(screen.getByText("Lazy consensus")).toBeInTheDocument();
expect(screen.getByText("Conflict ladder")).toBeInTheDocument();
// Demo chips from `finalReview.json` must not leak through once the
// user has real selections. The fallback still says "Open Admission".
expect(screen.queryByText("Open Admission")).not.toBeInTheDocument();
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Signal, missing words" }),
).toBeInTheDocument();
});
it("warns when binding arbitration sits next to a join that records no yes", async () => {
@@ -1293,15 +1301,15 @@ describe("FinalReviewScreen — edit published title and description", () => {
).toBeInTheDocument();
});
it("does not expose click-to-edit title or description on default final review", () => {
it("exposes click-to-edit title and description on default final review", () => {
render(
<FinalReviewWithFlowState
title="Oak"
communityContext="Visible body"
/>,
);
expect(screen.queryByTestId("rule-title-edit")).not.toBeInTheDocument();
expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument();
expect(screen.getByTestId("rule-title-edit")).toBeInTheDocument();
expect(screen.getByTestId("rule-description-edit")).toBeInTheDocument();
});
it("opens Save modal from title click and updates title", async () => {
+14
View File
@@ -287,4 +287,18 @@ describe("MultiSelect – behaviour specifics", () => {
);
expect(screen.getByText("Pick one")).toBeInTheDocument();
});
it("shows a flag after the add chip in the same row", () => {
render(
<MultiSelect
options={[]}
addButton
addButtonText="Add card"
flag="No card yet. Add one before you finish."
/>,
);
expect(
screen.getByText("No card yet. Add one before you finish."),
).toBeInTheDocument();
});
});
+36 -22
View File
@@ -10,9 +10,21 @@ const NAMES = {
conflict: "Conflict management",
};
function rowsWith(
chipsByName: Partial<Record<string, string[]>>,
): { name: string; chips: string[] }[] {
return [
"Values",
"Communication",
"Membership",
"Decision-making",
"Conflict management",
].map((name) => ({ name, chips: chipsByName[name] ?? [] }));
}
describe("buildFinalReviewCategoriesFromState", () => {
it("returns [] when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]);
it("returns five empty rows when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual(rowsWith({}));
});
it("resolves method ids to labels from message presets", () => {
@@ -33,7 +45,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
expect(byName.get("Membership")).toEqual(["Open admission"]);
expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]);
expect(byName.get("Conflict management")).toEqual(["Conflict ladder"]);
expect(byName.has("Values")).toBe(false);
expect(byName.get("Values")).toEqual([]);
});
it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => {
@@ -46,9 +58,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility", "Resilience"] },
]);
expect(rows).toEqual(
rowsWith({ Values: ["Accessibility", "Resilience"] }),
);
});
it("drops unknown ids silently instead of inserting empty labels", () => {
@@ -56,7 +68,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "bogus-id"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
expect(rows).toEqual(rowsWith({ Communication: ["Signal"] }));
});
it("resolves user-authored method ids from customMethodCardMetaById", () => {
@@ -77,7 +89,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "signal"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
expect(rows).toEqual(rowsWith({ Communication: ["Signal"] }));
});
it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
@@ -98,10 +110,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: [],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Consciousness", "Ecology"] },
{ name: "Communication", chips: ["Signal"] },
]);
expect(rows).toEqual(
rowsWith({
Values: ["Consciousness", "Ecology"],
Communication: ["Signal"],
}),
);
});
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
@@ -115,9 +129,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["in-person-meetings"],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Communication", chips: ["In-person meetings"] },
]);
expect(rows).toEqual(
rowsWith({ Communication: ["In-person meetings"] }),
);
});
it("shows custom communication chips when sections exist and selections include a UUID", () => {
@@ -153,10 +167,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility"] },
{ name: "Communication", chips: ["Signal"] },
]);
expect(rows).toEqual(
rowsWith({
Values: ["Accessibility"],
Communication: ["Signal"],
}),
);
});
it("prefers the chip snapshot over a duplicated Values section in sections", () => {
@@ -177,8 +193,6 @@ describe("buildFinalReviewCategoriesFromState", () => {
],
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([
{ name: "Values", chips: ["Accessibility"] },
]);
expect(rows).toEqual(rowsWith({ Values: ["Accessibility"] }));
});
});
+5 -1
View File
@@ -20,7 +20,11 @@ describe("create footer messages", () => {
expect(messages.create.footer.confirmSize).toBe("Confirm size");
});
it("exposes publishCommunityRule for the final-review footer CTA", () => {
it("exposes Continue for the final-review footer CTA", () => {
expect(messages.create.footer.continue).toBe("Continue");
});
it("exposes publishCommunityRule for the edit-rule footer CTA", () => {
expect(messages.create.footer.publishCommunityRule).toBe(
"Publish CommunityRule",
);
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { isFinalReviewContinueBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels";
import { communityInitialsFromTitle } from "../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../lib/create/finalReviewChipWarnings";
import type { CreateFlowState } from "../../app/(app)/create/types";
const completeCards: CreateFlowState = {
title: "Oak Park Commons",
selectedCommunicationMethodIds: ["signal"],
selectedMembershipMethodIds: ["open-admission"],
selectedDecisionApproachIds: ["lazy-consensus"],
selectedConflictManagementIds: ["conflict-ladder"],
};
describe("isFinalReviewContinueBlocked", () => {
it("blocks when the community name is empty", () => {
expect(
isFinalReviewContinueBlocked({
...completeCards,
title: " ",
}),
).toBe(true);
});
it("blocks when any of the four questions has no card", () => {
expect(
isFinalReviewContinueBlocked({
...completeCards,
selectedMembershipMethodIds: [],
}),
).toBe(true);
});
it("does not block when values are empty and the four questions have cards", () => {
expect(isFinalReviewContinueBlocked(completeCards)).toBe(false);
});
});
describe("finalReviewChipHasWarning", () => {
it("warns when a value is missing meaning or signals", () => {
expect(
finalReviewChipHasWarning(
{
coreValueDetailsByChipId: {
"1": { meaning: "Care", signals: "" },
},
},
"coreValues",
"1",
),
).toBe(true);
});
it("warns when a method card has no saved words", () => {
expect(
finalReviewChipHasWarning({}, "communication", "signal"),
).toBe(true);
});
it("does not warn when meaning and signals are both filled", () => {
expect(
finalReviewChipHasWarning(
{
coreValueDetailsByChipId: {
"1": { meaning: "Care", signals: "We talk over people." },
},
},
"coreValues",
"1",
),
).toBe(false);
});
});
describe("communityInitialsFromTitle", () => {
it("uses the first letters of the first two words", () => {
expect(communityInitialsFromTitle("Oak Park Commons")).toBe("OP");
});
it("uses two letters from a single word", () => {
expect(communityInitialsFromTitle("Neighbors")).toBe("NE");
});
});
+19 -22
View File
@@ -16,9 +16,9 @@ import {
} from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => {
it("places confirm-stakeholders immediately before final-review", () => {
const i = FLOW_STEP_ORDER.indexOf("confirm-stakeholders");
const j = FLOW_STEP_ORDER.indexOf("final-review");
it("places final-review immediately before confirm-stakeholders", () => {
const i = FLOW_STEP_ORDER.indexOf("final-review");
const j = FLOW_STEP_ORDER.indexOf("confirm-stakeholders");
expect(i).toBeGreaterThanOrEqual(0);
expect(j).toBe(i + 1);
});
@@ -27,8 +27,8 @@ describe("flowSteps", () => {
expect(getNextStep("communication-methods")).toBe("membership-methods");
expect(getNextStep("membership-methods")).toBe("decision-approaches");
expect(getNextStep("decision-approaches")).toBe("conflict-management");
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders")).toBe("final-review");
expect(getNextStep("conflict-management")).toBe("final-review");
expect(getNextStep("final-review")).toBe("confirm-stakeholders");
});
it("getNextStep returns null for last step or invalid", () => {
@@ -39,7 +39,8 @@ describe("flowSteps", () => {
});
it("getPreviousStep returns prior step or null", () => {
expect(getPreviousStep("final-review")).toBe("confirm-stakeholders");
expect(getPreviousStep("final-review")).toBe("conflict-management");
expect(getPreviousStep("confirm-stakeholders")).toBe("final-review");
expect(getPreviousStep("informational")).toBeNull();
expect(getPreviousStep(null)).toBeNull();
});
@@ -91,19 +92,17 @@ describe("flowSteps", () => {
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
it("useWithoutChangesIdentityOnly walks name → description → photo → stakeholders", () => {
it("useWithoutChangesIdentityOnly walks name → description → photo → final-review", () => {
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(getNextStep("community-upload", opts)).toBe("final-review");
expect(getNextStep("final-review", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("community-context", opts)).toBe("community-name");
expect(getPreviousStep("community-upload", opts)).toBe("community-context");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getPreviousStep("final-review", opts)).toBe("community-upload");
expect(getPreviousStep("community-name", opts)).toBeNull();
});
@@ -112,16 +111,14 @@ describe("flowSteps", () => {
skipCommunitySave: true,
useWithoutChangesIdentityOnly: true,
} as const;
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getNextStep("community-upload", opts)).toBe("final-review");
expect(getPreviousStep("final-review", opts)).toBe("community-upload");
});
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => {
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on final-review", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
"final-review",
undefined,
"mutual-aid-mondays",
),
@@ -138,10 +135,10 @@ describe("flowSteps", () => {
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
});
it("resolveCreateFlowBackTarget uses photo step behind stakeholders on the identity-only path", () => {
it("resolveCreateFlowBackTarget uses photo step behind final-review on the identity-only path", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
"final-review",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
@@ -151,13 +148,13 @@ describe("flowSteps", () => {
it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => {
expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined),
).toEqual({ kind: "step", step: "conflict-management" });
).toEqual({ kind: "step", step: "final-review" });
});
it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => {
expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "),
).toEqual({ kind: "step", step: "conflict-management" });
).toEqual({ kind: "step", step: "final-review" });
});
it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => {
@@ -129,7 +129,7 @@ describe("useTemplateReviewActions", () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(router.push).toHaveBeenCalledWith("/create/final-review");
expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.setupTraits).toEqual({ employer: true });