diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 33d3f3c..fa465cd 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -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,12 +1189,14 @@ function CreateFlowLayoutContent({ goToNextStep(); }} > - {isFinalReviewLike - ? isPublishing - ? messages.create.reviewAndComplete.publish - .publishButtonPublishing - : footer.publishCommunityRule - : getDefaultFooterLabel(currentStep, footer)} + {currentStep === "final-review" + ? footer.continue + : isFinalReviewLike + ? isPublishing + ? messages.create.reviewAndComplete.publish + .publishButtonPublishing + : footer.publishCommunityRule + : getDefaultFooterLabel(currentStep, footer)} ) : null } diff --git a/app/(app)/create/hooks/useCreateFlowNavigation.ts b/app/(app)/create/hooks/useCreateFlowNavigation.ts index 92780e1..70e3a1f 100644 --- a/app/(app)/create/hooks/useCreateFlowNavigation.ts +++ b/app/(app)/create/hooks/useCreateFlowNavigation.ts @@ -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 diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index e44321b..ae70e53 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -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; }; @@ -268,7 +268,7 @@ export function useTemplateReviewActions({ }); router.push( fromCreateWizard - ? createFlowStepPath("confirm-stakeholders") + ? createFlowStepPath("final-review") : createFlowStepPath("community-name"), ); }, [ diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 7f2bcc1..c1d57b3 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -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, - })), - })), + values: get(0), + communication: get(1), + membership: get(2), + decisions: get(3), + conflict: get(4), }; } @@ -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({ 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,28 +312,24 @@ export function FinalReviewScreen({ onClose={() => setActiveReadOnlyDetail(null)} detail={activeReadOnlyDetail} /> - {variant === "editPublished" ? ( - <> - setTitleModalOpen(false)} - initialValue={rawTitleForModal} - onSave={(value) => { - markCreateFlowInteraction(); - updateState({ title: value }); - }} - /> - setCommunityContextModalOpen(false)} - initialValue={rawCommunityContextForModal} - onSave={(value) => { - markCreateFlowInteraction(); - updateState({ communityContext: value, summary: value }); - }} - /> - - ) : null} + setTitleModalOpen(false)} + initialValue={rawTitleForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ title: value }); + }} + /> + setCommunityContextModalOpen(false)} + initialValue={rawCommunityContextForModal} + onSave={(value) => { + markCreateFlowInteraction(); + updateState({ communityContext: value, summary: value }); + }} + /> ); } diff --git a/app/(app)/create/utils/createFlowFooterLabels.ts b/app/(app)/create/utils/createFlowFooterLabels.ts index 865126a..54392d1 100644 --- a/app/(app)/create/utils/createFlowFooterLabels.ts +++ b/app/(app)/create/utils/createFlowFooterLabels.ts @@ -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, diff --git a/app/(app)/create/utils/createFlowProportionProgress.ts b/app/(app)/create/utils/createFlowProportionProgress.ts index c30899b..68c95a8 100644 --- a/app/(app)/create/utils/createFlowProportionProgress.ts +++ b/app/(app)/create/utils/createFlowProportionProgress.ts @@ -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; diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts index f435d80..ff3f146 100644 --- a/app/(app)/create/utils/createFlowScreenRegistry.ts +++ b/app/(app)/create/utils/createFlowScreenRegistry.ts @@ -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", diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index 08d284c..b5ea210 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -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([ "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 ) { diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts index 1bca614..78bd705 100644 --- a/app/components/cards/Rule/Rule.types.ts +++ b/app/components/cards/Rule/Rule.types.ts @@ -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?: ( diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index 687cc41..d56bba6 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -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" /> ))} diff --git a/app/components/controls/Chip/Chip.container.tsx b/app/components/controls/Chip/Chip.container.tsx index cb3bcc6..0d9951f 100644 --- a/app/components/controls/Chip/Chip.container.tsx +++ b/app/components/controls/Chip/Chip.container.tsx @@ -21,6 +21,7 @@ const ChipContainer = memo( onRemove, onCheck, onClose, + hasWarning = false, ariaLabel, }) => { const t = useTranslation("controlsChrome"); @@ -89,11 +90,19 @@ const ChipContainer = memo( 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")} diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index f0d2974..e341981 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -48,6 +48,11 @@ export interface ChipProps { * Optional callback when custom chip is closed/removed. */ onClose?: (event: React.MouseEvent) => 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) => void; onCheck?: (value: string, event: React.MouseEvent) => void; onClose?: (event: React.MouseEvent) => void; + hasWarning?: boolean; inputValue?: string; onInputChange?: (value: string) => void; onInputKeyDown?: (event: React.KeyboardEvent) => void; diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 1f1a394..361953a 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -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 ? ( + + ) : isSelected ? ( ( onAddClick, addButton: addButtonProp = true, addButtonText, + flag, formHeader = true, helperText, errorMessage, @@ -45,11 +46,13 @@ const MultiSelectContainer = memo( 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} diff --git a/app/components/controls/MultiSelect/MultiSelect.types.ts b/app/components/controls/MultiSelect/MultiSelect.types.ts index 9f8a076..02b5a08 100644 --- a/app/components/controls/MultiSelect/MultiSelect.types.ts +++ b/app/components/controls/MultiSelect/MultiSelect.types.ts @@ -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; diff --git a/app/components/controls/MultiSelect/MultiSelect.view.tsx b/app/components/controls/MultiSelect/MultiSelect.view.tsx index ce6bcaa..67b7f15 100644 --- a/app/components/controls/MultiSelect/MultiSelect.view.tsx +++ b/app/components/controls/MultiSelect/MultiSelect.view.tsx @@ -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,13 +97,19 @@ function MultiSelectView({ palette={palette} size={chipSize} disabled={chipDisabled} + hasWarning={option.hasWarning} ariaLabel={ isCustom || !option.label ? undefined - : (isSelected - ? chipDeselectAriaLabel - : chipSelectAriaLabel - ).replace("{label}", option.label) + : option.hasWarning + ? chipMissingWordsAriaLabel.replace( + "{label}", + option.label, + ) + : (isSelected + ? chipDeselectAriaLabel + : chipSelectAriaLabel + ).replace("{label}", option.label) } onClick={() => { if (!isCustom && !chipDisabled && onChipClick) { @@ -180,6 +189,22 @@ function MultiSelectView({ )} )} + {flag ? ( + + + {flag} + + ) : null} {errorMessage ? (

(); + 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, diff --git a/lib/create/communityInitials.ts b/lib/create/communityInitials.ts new file mode 100644 index 0000000..6e6d7f6 --- /dev/null +++ b/lib/create/communityInitials.ts @@ -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(); +} diff --git a/lib/create/finalReviewChipWarnings.ts b/lib/create/finalReviewChipWarnings.ts new file mode 100644 index 0000000..aa1e7c5 --- /dev/null +++ b/lib/create/finalReviewChipWarnings.ts @@ -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], + ); + } +} diff --git a/messages/en/components/controlsChrome.json b/messages/en/components/controlsChrome.json index d60b4a9..cd14c12 100644 --- a/messages/en/components/controlsChrome.json +++ b/messages/en/components/controlsChrome.json @@ -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", diff --git a/messages/en/create/reviewAndComplete/finalReview.json b/messages/en/create/reviewAndComplete/finalReview.json index ee0cde5..77ca4b3 100644 --- a/messages/en/create/reviewAndComplete/finalReview.json +++ b/messages/en/create/reviewAndComplete/finalReview.json @@ -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", diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx index 5bfb662..787b63b 100644 --- a/tests/components/Chip.test.tsx +++ b/tests/components/Chip.test.tsx @@ -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( + , + ); + const chip = screen.getByRole("button", { + name: "Signal, missing words", + }); + expect(chip.querySelector("svg")).not.toBeNull(); + }); }); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 25b9237..102cb29 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -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(); expect( screen.getByRole("heading", { - name: "Review your CommunityRule", + name: "Review your Rule", }), ).toBeInTheDocument(); }); @@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => { it("renders lockup description", () => { render(); 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(); - 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( , ); - 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 () => { diff --git a/tests/components/MultiSelect.test.tsx b/tests/components/MultiSelect.test.tsx index ac8ca39..b86abd2 100644 --- a/tests/components/MultiSelect.test.tsx +++ b/tests/components/MultiSelect.test.tsx @@ -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( + , + ); + expect( + screen.getByText("No card yet. Add one before you finish."), + ).toBeInTheDocument(); + }); }); diff --git a/tests/unit/buildFinalReviewCategories.test.ts b/tests/unit/buildFinalReviewCategories.test.ts index f9f893d..3ae1ad5 100644 --- a/tests/unit/buildFinalReviewCategories.test.ts +++ b/tests/unit/buildFinalReviewCategories.test.ts @@ -10,9 +10,21 @@ const NAMES = { conflict: "Conflict management", }; +function rowsWith( + chipsByName: Partial>, +): { 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"] })); }); }); diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts index bbbbd7b..a655911 100644 --- a/tests/unit/createFooterMessages.test.ts +++ b/tests/unit/createFooterMessages.test.ts @@ -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", ); diff --git a/tests/unit/finalReviewContinue.test.ts b/tests/unit/finalReviewContinue.test.ts new file mode 100644 index 0000000..2351fcf --- /dev/null +++ b/tests/unit/finalReviewContinue.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index d833026..c96567a 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -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", () => { diff --git a/tests/unit/hooks/useTemplateReviewActions.test.tsx b/tests/unit/hooks/useTemplateReviewActions.test.tsx index 458f276..2f36b56 100644 --- a/tests/unit/hooks/useTemplateReviewActions.test.tsx +++ b/tests/unit/hooks/useTemplateReviewActions.test.tsx @@ -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 });