Show the whole Rule on one review card, with quiet flags for missing cards and Continue once each question has one.

This commit is contained in:
adilallo committed 2026-11-06 08:36:34 -07:00
1 parent cc878e3c19
commit 6e2e1915ae
30 files changed
+571 -218

No files matched your search

+20 -9
View File
@@ -90,6 +90,7 @@ import {
isCommunityNameStepBlocked, isCommunityNameStepBlocked,
isCommunitySizeStepBlocked, isCommunitySizeStepBlocked,
isCommunityStructureStepBlocked, isCommunityStructureStepBlocked,
isFinalReviewContinueBlocked,
} from "./utils/createFlowFooterLabels"; } from "./utils/createFlowFooterLabels";
import { import {
EMPTY_SETUP_TRAITS, EMPTY_SETUP_TRAITS,
@@ -912,7 +913,7 @@ function CreateFlowLayoutContent({
progressBar={ progressBar={
!isTemplateReviewRoute && !isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute && !isCreateTemplatesPickerRoute &&
!isFinalReviewLike && currentStep !== "edit-rule" &&
reviewReturnTarget !== "edit-rule" reviewReturnTarget !== "edit-rule"
} }
proportionBarProgress={proportionBarProgress} proportionBarProgress={proportionBarProgress}
@@ -1146,14 +1147,22 @@ function CreateFlowLayoutContent({
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={ disabled={
isPublishing || (isFinalReviewLike && !sessionResolved) isPublishing ||
(isEditRuleStep && !sessionResolved) ||
(currentStep === "final-review" &&
isFinalReviewContinueBlocked(state))
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
if (isFinalReviewLike) { if (isEditRuleStep) {
void handleFinalize(); void handleFinalize();
return; return;
} }
if (currentStep === "final-review") {
if (isFinalReviewContinueBlocked(state)) return;
goToNextStep();
return;
}
markAttempted(); markAttempted();
if ( if (
isCommunityStructureStepBlocked( isCommunityStructureStepBlocked(
@@ -1180,12 +1189,14 @@ function CreateFlowLayoutContent({
goToNextStep(); goToNextStep();
}} }}
> >
{isFinalReviewLike {currentStep === "final-review"
? isPublishing ? footer.continue
? messages.create.reviewAndComplete.publish : isFinalReviewLike
.publishButtonPublishing ? isPublishing
: footer.publishCommunityRule ? messages.create.reviewAndComplete.publish
: getDefaultFooterLabel(currentStep, footer)} .publishButtonPublishing
: footer.publishCommunityRule
: getDefaultFooterLabel(currentStep, footer)}
</Button> </Button>
) : null ) : null
} }
@@ -38,7 +38,7 @@ const blurActiveElement = (): void => {
* still needs identity (direct from a template) walks name / description / * still needs identity (direct from a template) walks name / description /
* photo, then Back from `community-name` returns to * photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip * `/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`. * to the template instead of `conflict-management`.
* *
* Template review footer Back uses {@link buildTemplateReviewHref}’s * 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 → * `/create/community-name` with a pin so Next/Back collect only name →
* description → photo, and drops leftover setup answers along with the * description → photo, and drops leftover setup answers along with the
* other identity fields. In-flow (`?fromFlow=1`, community steps already * 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>; handleUseWithoutChanges: () => Promise<void>;
}; };
@@ -268,7 +268,7 @@ export function useTemplateReviewActions({
}); });
router.push( router.push(
fromCreateWizard fromCreateWizard
? createFlowStepPath("confirm-stakeholders") ? createFlowStepPath("final-review")
: createFlowStepPath("community-name"), : createFlowStepPath("community-name"),
); );
}, [ }, [
@@ -14,8 +14,11 @@ import {
} from "../../components/CreateFlowLockupCardStepShell"; } from "../../components/CreateFlowLockupCardStepShell";
import { import {
buildFinalReviewCategoryRowsDetailed, buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
type FinalReviewCategoryRowDetailed, type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories"; } from "../../../../../lib/create/buildFinalReviewCategories";
import { communityInitialsFromTitle } from "../../../../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../../../../lib/create/finalReviewChipWarnings";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch"; import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type { import type {
TemplateChipDetail, TemplateChipDetail,
@@ -32,10 +35,6 @@ import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisp
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor"; import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep"; import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor"; import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import { import {
persistDraftPreviewSurfaceColor, persistDraftPreviewSurfaceColor,
@@ -51,53 +50,17 @@ function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
return joinsWithoutAYes && conflict.includes("binding-arbitration"); return joinsWithoutAYes && conflict.includes("binding-arbitration");
} }
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ function categoryNamesFromMessages(
"coreValues", categories: readonly { name: string }[],
"communication", ): FinalReviewCategoryNames {
"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[];
} {
const get = (i: number): string => const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : ""; typeof categories[i]?.name === "string" ? categories[i].name : "";
return { return {
names: { values: get(0),
values: get(0), communication: get(1),
communication: get(1), membership: get(2),
membership: get(2), decisions: get(3),
decisions: get(3), conflict: get(4),
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 { categories: finalReviewCategories } = useMemo(() => {
const { names, rows: fallbackRows } = readFallbackCategoryRows( const copy = m.create.reviewAndComplete.finalReview;
m.create.reviewAndComplete.finalReview.categories, const names = categoryNamesFromMessages(copy.categories);
);
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] = const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
derived.length > 0 ? derived : fallbackRows; buildFinalReviewCategoryRowsDetailed(state, names);
const usingFallbackRows = derived.length === 0;
const lookup = new Map< const lookup = new Map<
string, string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail } { target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>(); >();
const cats: Category[] = rowsToRender.map((row, rowIndex) => { const cats: Category[] = rowsToRender.map((row) => {
const effectiveGroupKey: TemplateFacetGroupKey | null = const effectiveGroupKey: TemplateFacetGroupKey | null = row.groupKey;
row.groupKey ??
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
? FACET_FALLBACK_ORDER[rowIndex]
: null);
const reviewReturn = const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const); 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 chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`; const chipId = `${row.name}-${idx}`;
@@ -190,12 +157,19 @@ export function FinalReviewScreen({
id: chipId, id: chipId,
label: entry.label, label: entry.label,
state: "unselected" as const, state: "unselected" as const,
hasWarning: finalReviewChipHasWarning(
state,
entry.groupKey,
entry.overrideKey,
),
}; };
}); });
return { return {
name: row.name, name: row.name,
chipOptions, chipOptions,
addButton: effectiveGroupKey != null, addButton: effectiveGroupKey != null,
addButtonText,
flag,
onChipClick: (_categoryName: string, chipId: string) => { onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId); const hit = lookup.get(chipId);
if (!hit) return; if (!hit) return;
@@ -219,7 +193,7 @@ export function FinalReviewScreen({
}); });
return { categories: cats }; return { categories: cats };
}, [ }, [
m.create.reviewAndComplete.finalReview.categories, m.create.reviewAndComplete.finalReview,
state, state,
markCreateFlowInteraction, markCreateFlowInteraction,
goToStep, goToStep,
@@ -249,8 +223,7 @@ export function FinalReviewScreen({
const rawTitleForModal = const rawTitleForModal =
typeof state.title === "string" ? state.title : ""; typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint = const descriptionEmptyHint = t("communityContextEditModal.emptyHint");
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl); const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
const storedFallback = const storedFallback =
@@ -305,34 +278,21 @@ export function FinalReviewScreen({
<Rule <Rule
title={ruleCardTitle} title={ruleCardTitle}
description={ruleCardDescription} description={ruleCardDescription}
onTitleClick={ onTitleClick={() => setTitleModalOpen(true)}
variant === "editPublished" titleEditAriaLabel={t("titleEditModal.ariaEditTitle")}
? () => setTitleModalOpen(true) onDescriptionClick={() => setCommunityContextModalOpen(true)}
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={
variant === "editPublished"
? () => setCommunityContextModalOpen(true)
: undefined
}
descriptionEmptyHint={descriptionEmptyHint} descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={ descriptionEditAriaLabel={t("communityContextEditModal.ariaEditDescription")}
variant === "editPublished"
? t("communityContextEditModal.ariaEditDescription")
: undefined
}
size={mdUp ? "L" : "M"} size={mdUp ? "L" : "M"}
expanded={true} expanded={true}
descriptionAtBottom descriptionAtBottom
backgroundColor="bg-transparent" backgroundColor="bg-transparent"
surfaceColor={surfaceColor} surfaceColor={surfaceColor}
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))} logoUrl={avatarUrl}
logoAlt={ruleCardTitle} logoAlt={ruleCardTitle}
communityInitials={
avatarUrl ? undefined : communityInitialsFromTitle(ruleCardTitle)
}
categories={finalReviewCategories} categories={finalReviewCategories}
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`} className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
onClick={() => {}} onClick={() => {}}
@@ -352,28 +312,24 @@ export function FinalReviewScreen({
onClose={() => setActiveReadOnlyDetail(null)} onClose={() => setActiveReadOnlyDetail(null)}
detail={activeReadOnlyDetail} detail={activeReadOnlyDetail}
/> />
{variant === "editPublished" ? ( <FinalReviewTitleEditModal
<> isOpen={titleModalOpen}
<FinalReviewTitleEditModal onClose={() => setTitleModalOpen(false)}
isOpen={titleModalOpen} initialValue={rawTitleForModal}
onClose={() => setTitleModalOpen(false)} onSave={(value) => {
initialValue={rawTitleForModal} markCreateFlowInteraction();
onSave={(value) => { updateState({ title: value });
markCreateFlowInteraction(); }}
updateState({ title: value }); />
}} <FinalReviewCommunityContextEditModal
/> isOpen={communityContextModalOpen}
<FinalReviewCommunityContextEditModal onClose={() => setCommunityContextModalOpen(false)}
isOpen={communityContextModalOpen} initialValue={rawCommunityContextForModal}
onClose={() => setCommunityContextModalOpen(false)} onSave={(value) => {
initialValue={rawCommunityContextForModal} markCreateFlowInteraction();
onSave={(value) => { updateState({ communityContext: value, summary: value });
markCreateFlowInteraction(); }}
updateState({ communityContext: value, summary: value }); />
}}
/>
</>
) : null}
</CreateFlowLockupCardStepShell> </CreateFlowLockupCardStepShell>
); );
} }
@@ -1,5 +1,10 @@
import type footerMessages from "../../../../messages/en/create/footer.json"; 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; type FooterMessages = typeof footerMessages;
@@ -9,9 +14,10 @@ type FooterMessages = typeof footerMessages;
* fires `goToNextStep` for steps without a bespoke footer). Steps absent * fires `goToNextStep` for steps without a bespoke footer). Steps absent
* from this map fall back to `footer.next`. * from this map fall back to `footer.next`.
* *
* `final-review` is handled separately by the caller because its label * `final-review` uses Continue (gated by {@link isFinalReviewContinueBlocked}).
* also depends on the in-flight publish flag (`publishButtonPublishing` * `edit-rule` is handled separately by the caller because its label also
* vs `publishCommunityRule`). * depends on the in-flight publish flag (`publishButtonPublishing` vs
* `publishCommunityRule`).
*/ */
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap< const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep, CreateFlowStep,
@@ -53,10 +59,42 @@ export function isCommunitySizeStepBlocked(
return !hasNonEmptyId(selectedCommunitySizeIds); 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. * Resolve the localized label for the default "next-step" footer button.
* Returns the per-step override when one is registered, otherwise * Returns the per-step override when one is registered, otherwise
* `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( export function getDefaultFooterLabel(
step: CreateFlowStep | null | undefined, 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-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`) "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-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", // final-review
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // completed "3-2", // completed
] as const; ] as const;
@@ -123,18 +123,18 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.customRule.conflictManagement", messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-213155",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": { "confirm-stakeholders": {
layoutKind: "select", layoutKind: "select",
figmaNodeId: "21104-46594", figmaNodeId: "21104-46594",
messageNamespace: "create.reviewAndComplete.confirmStakeholders", messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"edit-rule": { "edit-rule": {
layoutKind: "review", layoutKind: "review",
figmaNodeId: "20907-212767", figmaNodeId: "20907-212767",
+5 -5
View File
@@ -27,8 +27,8 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"membership-methods", "membership-methods",
"decision-approaches", "decision-approaches",
"conflict-management", "conflict-management",
"confirm-stakeholders",
"final-review", "final-review",
"confirm-stakeholders",
"completed", "completed",
] as const; ] as const;
@@ -61,7 +61,7 @@ export type CreateFlowNavigationOptions = {
* Community + custom-rule steps skipped when * Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set. * {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` → * 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>([ const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational", "informational",
@@ -131,9 +131,9 @@ export function getPreviousStep(
* Template **Use without changes** exceptions: * Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from * - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; 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 * - 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`. * `conflict-management`.
*/ */
export type CreateFlowBackTarget = export type CreateFlowBackTarget =
@@ -157,7 +157,7 @@ export function resolveCreateFlowBackTarget(
return { kind: "templateReview", slug }; return { kind: "templateReview", slug };
} }
if ( if (
currentStep === "confirm-stakeholders" && currentStep === "final-review" &&
slug.length > 0 && slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly !options?.useWithoutChangesIdentityOnly
) { ) {
+4
View File
@@ -6,6 +6,10 @@ export interface Category {
chipOptions: ChipOption[]; chipOptions: ChipOption[];
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */ /** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
addButton?: boolean; 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; onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void; onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: ( onCustomChipConfirm?: (
+2 -1
View File
@@ -472,7 +472,8 @@ export function RuleView({
addButton={ addButton={
!hideCategoryAddButton && category.addButton !== false !hideCategoryAddButton && category.addButton !== false
} }
addButtonText="" // Empty text for icon-only circular button addButtonText={category.addButtonText ?? ""}
flag={category.flag}
className="w-full" className="w-full"
/> />
))} ))}
@@ -21,6 +21,7 @@ const ChipContainer = memo<ChipProps>(
onRemove, onRemove,
onCheck, onCheck,
onClose, onClose,
hasWarning = false,
ariaLabel, ariaLabel,
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
@@ -89,11 +90,19 @@ const ChipContainer = memo<ChipProps>(
onRemove={onRemove} onRemove={onRemove}
onCheck={handleCheck} onCheck={handleCheck}
onClose={handleClose} onClose={handleClose}
hasWarning={hasWarning}
inputValue={isCustom ? inputValue : undefined} inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined} onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined} onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined} inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel ?? (isCustom ? undefined : label)} ariaLabel={
ariaLabel ??
(isCustom
? undefined
: hasWarning
? t("chipMissingWords").replace("{label}", label)
: label)
}
confirmAriaLabel={t("chipConfirm")} confirmAriaLabel={t("chipConfirm")}
typeToAddPlaceholder={t("chipTypeToAdd")} typeToAddPlaceholder={t("chipTypeToAdd")}
closeAriaLabel={t("chipClose")} closeAriaLabel={t("chipClose")}
@@ -48,6 +48,11 @@ export interface ChipProps {
* Optional callback when custom chip is closed/removed. * Optional callback when custom chip is closed/removed.
*/ */
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void; 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; ariaLabel?: string;
} }
@@ -62,6 +67,7 @@ export interface ChipViewProps {
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void; onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void; onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void; onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
hasWarning?: boolean;
inputValue?: string; inputValue?: string;
onInputChange?: (value: string) => void; onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void; onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
+9 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo, type CSSProperties } from "react"; import { memo, type CSSProperties } from "react";
import Icon from "../../asset/icon/Icon";
import type { ChipViewProps } from "./Chip.types"; import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle( function chipSurfaceStyle(
@@ -87,6 +88,7 @@ function ChipView({
onRemove, onRemove,
onCheck, onCheck,
onClose, onClose,
hasWarning = false,
inputValue, inputValue,
onInputChange, onInputChange,
onInputKeyDown, onInputKeyDown,
@@ -280,7 +282,13 @@ function ChipView({
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
> >
{isSelected ? ( {hasWarning ? (
<Icon
name="warning"
size={isSmall ? 12 : 16}
className="shrink-0 text-current"
/>
) : isSelected ? (
<svg <svg
aria-hidden aria-hidden
viewBox="0 0 12 12" viewBox="0 0 12 12"
@@ -20,6 +20,7 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick, onAddClick,
addButton: addButtonProp = true, addButton: addButtonProp = true,
addButtonText, addButtonText,
flag,
formHeader = true, formHeader = true,
helperText, helperText,
errorMessage, errorMessage,
@@ -45,11 +46,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick={onAddClick} onAddClick={onAddClick}
addButton={addButtonProp} addButton={addButtonProp}
addButtonText={addButtonText} addButtonText={addButtonText}
flag={flag}
addButtonAriaLabel={ addButtonAriaLabel={
addButtonText || t("multiSelectAddFallback") addButtonText || t("multiSelectAddFallback")
} }
chipSelectAriaLabel={t("chipSelect")} chipSelectAriaLabel={t("chipSelect")}
chipDeselectAriaLabel={t("chipDeselect")} chipDeselectAriaLabel={t("chipDeselect")}
chipMissingWordsAriaLabel={t("chipMissingWords")}
formHeader={formHeader} formHeader={formHeader}
helperText={helperText} helperText={helperText}
errorMessage={errorMessage} errorMessage={errorMessage}
@@ -7,6 +7,8 @@ export interface ChipOption {
id: string; id: string;
label: string; label: string;
state?: ChipStateValue; state?: ChipStateValue;
/** Figma Has Warning on Control / Chip. */
hasWarning?: boolean;
} }
export type MultiSelectSizeValue = "s" | "m"; export type MultiSelectSizeValue = "s" | "m";
@@ -49,6 +51,11 @@ export interface MultiSelectProps {
* Text for the add button * Text for the add button
*/ */
addButtonText?: string; 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). * Whether to show form header (label and help icon) above multi-select (Figma prop).
* @default true * @default true
@@ -93,7 +100,9 @@ export interface MultiSelectViewProps {
onAddClick?: () => void; onAddClick?: () => void;
addButton: boolean; addButton: boolean;
addButtonText?: string; addButtonText?: string;
flag?: string;
addButtonAriaLabel: string; addButtonAriaLabel: string;
chipMissingWordsAriaLabel: string;
chipSelectAriaLabel: string; chipSelectAriaLabel: string;
chipDeselectAriaLabel: string; chipDeselectAriaLabel: string;
formHeader: boolean; formHeader: boolean;
@@ -2,6 +2,7 @@
import { memo } from "react"; import { memo } from "react";
import Chip from "../Chip"; import Chip from "../Chip";
import Icon from "../../asset/icon/Icon";
import InputLabel from "../../type/InputLabel"; import InputLabel from "../../type/InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types"; import type { MultiSelectViewProps } from "./MultiSelect.types";
@@ -15,9 +16,11 @@ function MultiSelectView({
onAddClick, onAddClick,
addButton, addButton,
addButtonText, addButtonText,
flag,
addButtonAriaLabel, addButtonAriaLabel,
chipSelectAriaLabel, chipSelectAriaLabel,
chipDeselectAriaLabel, chipDeselectAriaLabel,
chipMissingWordsAriaLabel,
formHeader = true, formHeader = true,
helperText: helperTextProp, helperText: helperTextProp,
errorMessage, errorMessage,
@@ -94,13 +97,19 @@ function MultiSelectView({
palette={palette} palette={palette}
size={chipSize} size={chipSize}
disabled={chipDisabled} disabled={chipDisabled}
hasWarning={option.hasWarning}
ariaLabel={ ariaLabel={
isCustom || !option.label isCustom || !option.label
? undefined ? undefined
: (isSelected : option.hasWarning
? chipDeselectAriaLabel ? chipMissingWordsAriaLabel.replace(
: chipSelectAriaLabel "{label}",
).replace("{label}", option.label) option.label,
)
: (isSelected
? chipDeselectAriaLabel
: chipSelectAriaLabel
).replace("{label}", option.label)
} }
onClick={() => { onClick={() => {
if (!isCustom && !chipDisabled && onChipClick) { if (!isCustom && !chipDisabled && onChipClick) {
@@ -180,6 +189,22 @@ function MultiSelectView({
)} )}
</button> </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> </div>
{errorMessage ? ( {errorMessage ? (
<p <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 Community** | Intro, naming, structure, context, size, optional setup questions, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `setup-questions` → `community-upload` → `community-save` → `review` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` | | **Create Custom CommunityRule** | Author the CommunityRule content and structure (core values + four card-stack categories). | `core-values` → `communication-methods` → `membership-methods` → `decision-approaches` → `conflict-management` |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` | | **Review and complete** | 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. 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` | | 12 | Create Custom CommunityRule | `membership-methods` | `/create/membership-methods` |
| 13 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` | | 13 | Create Custom CommunityRule | `decision-approaches` | `/create/decision-approaches` |
| 14 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` | | 14 | Create Custom CommunityRule | `conflict-management` | `/create/conflict-management` |
| 15 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` | | 15 | Review and complete | `final-review` | `/create/final-review` |
| 16 | Review and complete | `final-review` | `/create/final-review` | | 16 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 17 | Review and complete | `completed` | `/create/completed` | | 17 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)). **Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)).
@@ -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. **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. **Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run.
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination. - **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, setup questions, save, community review, and custom-rule authoring are skipped. Back from name returns to template review. - **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/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/confirm-stakeholders`. Back returns to template review. - **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/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`. 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; 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 }; type MethodPreset = { id: string; label: string };
/** /**
@@ -230,7 +280,7 @@ export function buildFinalReviewCategoryRowsDetailed(
if (entries.length === 0) continue; if (entries.length === 0) continue;
rows.push({ name: s.categoryName, groupKey, entries }); rows.push({ name: s.categoryName, groupKey, entries });
} }
return rows; return ensureCanonicalFinalReviewRows(rows, names);
} }
const rows: FinalReviewCategoryRowDetailed[] = [ 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, * against the curated message `methods[]` list to get the display labels,
* matching what the user saw as chips in-flow. * matching what the user saw as chips in-flow.
* *
* Empty categories are filtered out so the review card doesn't render headings * Empty categories stay on the card so add chips and flags can show. The five
* with no chips. If nothing in state resolves to any chip, the caller should * named rows always render, in values-first order.
* fall back to the demo categories shipped in `finalReview.json`.
*/ */
export function buildFinalReviewCategoriesFromState( export function buildFinalReviewCategoriesFromState(
state: CreateFlowState, 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", "chipClose": "Close",
"chipSelect": "Select {label}", "chipSelect": "Select {label}",
"chipDeselect": "Deselect {label}", "chipDeselect": "Deselect {label}",
"chipMissingWords": "{label}, missing words",
"selectPlaceholder": "Choose an option", "selectPlaceholder": "Choose an option",
"selectAriaLabel": "Select an option", "selectAriaLabel": "Select an option",
"multiSelectAddDefault": "Add organization type", "multiSelectAddDefault": "Add organization type",
@@ -1,9 +1,15 @@
{ {
"title": "Review your CommunityRule", "title": "Review your Rule",
"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.", "description": "Here's what other people will see.",
"editPublishedTitle": "Edit your CommunityRule", "editPublishedTitle": "Edit your CommunityRule",
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.", "editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.",
"ruleCardTitleFallback": "Your community", "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": { "chipEditModal": {
"saveButton": "Save", "saveButton": "Save",
"readOnlyCloseButton": "Close", "readOnlyCloseButton": "Close",
@@ -35,7 +41,7 @@
}, },
{ {
"name": "Membership", "name": "Membership",
"chips": ["Open Admission"] "chips": ["Open admission"]
}, },
{ {
"name": "Decision-making", "name": "Decision-making",
+10
View File
@@ -64,4 +64,14 @@ describe("Chip", () => {
expect(chip).toHaveAttribute("aria-pressed", "true"); expect(chip).toHaveAttribute("aria-pressed", "true");
expect(chip.querySelector("svg[aria-hidden]")).not.toBeNull(); 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"; import type { CreateFlowState } from "../../app/(app)/create/types";
function catalogChipName(label: string) { function catalogChipName(label: string) {
return new RegExp(`^(Select|Deselect) ${label}$`); return new RegExp(
`^((Select|Deselect) ${label}|${label}, missing words)$`,
);
} }
async function confirmDiscardCustomizeEdits() { async function confirmDiscardCustomizeEdits() {
@@ -75,7 +77,7 @@ describe("FinalReviewScreen", () => {
render(<FinalReviewScreen />); render(<FinalReviewScreen />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "Review your CommunityRule", name: "Review your Rule",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => {
it("renders lockup description", () => { it("renders lockup description", () => {
render(<FinalReviewScreen />); render(<FinalReviewScreen />);
expect( expect(
screen.getByText( screen.getByText("Here's what other people will see."),
/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,
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -182,11 +182,19 @@ describe("FinalReviewScreen", () => {
expect(screen.getByText("Conflict management")).toBeInTheDocument(); 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 />); render(<FinalReviewScreen />);
expect(screen.getByText("Consciousness")).toBeInTheDocument(); expect(
expect(screen.getByText("Signal")).toBeInTheDocument(); screen.getByText("No values yet. You can add them any time."),
expect(screen.getByText("Open Admission")).toBeInTheDocument(); ).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("Lazy consensus")).toBeInTheDocument();
expect(screen.getByText("Conflict ladder")).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.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 () => { 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(); ).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( render(
<FinalReviewWithFlowState <FinalReviewWithFlowState
title="Oak" title="Oak"
communityContext="Visible body" communityContext="Visible body"
/>, />,
); );
expect(screen.queryByTestId("rule-title-edit")).not.toBeInTheDocument(); expect(screen.getByTestId("rule-title-edit")).toBeInTheDocument();
expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument(); expect(screen.getByTestId("rule-description-edit")).toBeInTheDocument();
}); });
it("opens Save modal from title click and updates title", async () => { 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(); 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", 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", () => { describe("buildFinalReviewCategoriesFromState", () => {
it("returns [] when state has no selections and no sections", () => { it("returns five empty rows when state has no selections and no sections", () => {
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]); expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual(rowsWith({}));
}); });
it("resolves method ids to labels from message presets", () => { 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("Membership")).toEqual(["Open admission"]);
expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]); expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]);
expect(byName.get("Conflict management")).toEqual(["Conflict ladder"]); 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)", () => { it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => {
@@ -46,9 +58,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
], ],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([ expect(rows).toEqual(
{ name: "Values", chips: ["Accessibility", "Resilience"] }, rowsWith({ Values: ["Accessibility", "Resilience"] }),
]); );
}); });
it("drops unknown ids silently instead of inserting empty labels", () => { it("drops unknown ids silently instead of inserting empty labels", () => {
@@ -56,7 +68,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "bogus-id"], selectedCommunicationMethodIds: ["signal", "bogus-id"],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); 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", () => { it("resolves user-authored method ids from customMethodCardMetaById", () => {
@@ -77,7 +89,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["signal", "signal"], selectedCommunicationMethodIds: ["signal", "signal"],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); 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)", () => { it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
@@ -98,10 +110,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: [], selectedCommunicationMethodIds: [],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([ expect(rows).toEqual(
{ name: "Values", chips: ["Consciousness", "Ecology"] }, rowsWith({
{ name: "Communication", chips: ["Signal"] }, Values: ["Consciousness", "Ecology"],
]); Communication: ["Signal"],
}),
);
}); });
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => { it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
@@ -115,9 +129,9 @@ describe("buildFinalReviewCategoriesFromState", () => {
selectedCommunicationMethodIds: ["in-person-meetings"], selectedCommunicationMethodIds: ["in-person-meetings"],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([ expect(rows).toEqual(
{ name: "Communication", chips: ["In-person meetings"] }, rowsWith({ Communication: ["In-person meetings"] }),
]); );
}); });
it("shows custom communication chips when sections exist and selections include a UUID", () => { it("shows custom communication chips when sections exist and selections include a UUID", () => {
@@ -153,10 +167,12 @@ describe("buildFinalReviewCategoriesFromState", () => {
], ],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([ expect(rows).toEqual(
{ name: "Values", chips: ["Accessibility"] }, rowsWith({
{ name: "Communication", chips: ["Signal"] }, Values: ["Accessibility"],
]); Communication: ["Signal"],
}),
);
}); });
it("prefers the chip snapshot over a duplicated Values section in sections", () => { it("prefers the chip snapshot over a duplicated Values section in sections", () => {
@@ -177,8 +193,6 @@ describe("buildFinalReviewCategoriesFromState", () => {
], ],
}; };
const rows = buildFinalReviewCategoriesFromState(state, NAMES); const rows = buildFinalReviewCategoriesFromState(state, NAMES);
expect(rows).toEqual([ expect(rows).toEqual(rowsWith({ Values: ["Accessibility"] }));
{ name: "Values", chips: ["Accessibility"] },
]);
}); });
}); });
+5 -1
View File
@@ -20,7 +20,11 @@ describe("create footer messages", () => {
expect(messages.create.footer.confirmSize).toBe("Confirm size"); 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( expect(messages.create.footer.publishCommunityRule).toBe(
"Publish CommunityRule", "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"; } from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => { describe("flowSteps", () => {
it("places confirm-stakeholders immediately before final-review", () => { it("places final-review immediately before confirm-stakeholders", () => {
const i = FLOW_STEP_ORDER.indexOf("confirm-stakeholders"); const i = FLOW_STEP_ORDER.indexOf("final-review");
const j = FLOW_STEP_ORDER.indexOf("final-review"); const j = FLOW_STEP_ORDER.indexOf("confirm-stakeholders");
expect(i).toBeGreaterThanOrEqual(0); expect(i).toBeGreaterThanOrEqual(0);
expect(j).toBe(i + 1); expect(j).toBe(i + 1);
}); });
@@ -27,8 +27,8 @@ describe("flowSteps", () => {
expect(getNextStep("communication-methods")).toBe("membership-methods"); expect(getNextStep("communication-methods")).toBe("membership-methods");
expect(getNextStep("membership-methods")).toBe("decision-approaches"); expect(getNextStep("membership-methods")).toBe("decision-approaches");
expect(getNextStep("decision-approaches")).toBe("conflict-management"); expect(getNextStep("decision-approaches")).toBe("conflict-management");
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders"); expect(getNextStep("conflict-management")).toBe("final-review");
expect(getNextStep("confirm-stakeholders")).toBe("final-review"); expect(getNextStep("final-review")).toBe("confirm-stakeholders");
}); });
it("getNextStep returns null for last step or invalid", () => { it("getNextStep returns null for last step or invalid", () => {
@@ -39,7 +39,8 @@ describe("flowSteps", () => {
}); });
it("getPreviousStep returns prior step or null", () => { 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("informational")).toBeNull();
expect(getPreviousStep(null)).toBeNull(); expect(getPreviousStep(null)).toBeNull();
}); });
@@ -91,19 +92,17 @@ describe("flowSteps", () => {
expect(getPreviousStep("communication-methods", opts)).toBe("core-values"); 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; const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context"); expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload"); expect(getNextStep("community-context", opts)).toBe("community-upload");
expect(getNextStep("community-size", opts)).toBe("community-upload"); expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("setup-questions", opts)).toBe("community-upload"); expect(getNextStep("setup-questions", opts)).toBe("community-upload");
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders"); expect(getNextStep("community-upload", opts)).toBe("final-review");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review"); expect(getNextStep("final-review", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("community-context", opts)).toBe("community-name"); expect(getPreviousStep("community-context", opts)).toBe("community-name");
expect(getPreviousStep("community-upload", opts)).toBe("community-context"); expect(getPreviousStep("community-upload", opts)).toBe("community-context");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe( expect(getPreviousStep("final-review", opts)).toBe("community-upload");
"community-upload",
);
expect(getPreviousStep("community-name", opts)).toBeNull(); expect(getPreviousStep("community-name", opts)).toBeNull();
}); });
@@ -112,16 +111,14 @@ describe("flowSteps", () => {
skipCommunitySave: true, skipCommunitySave: true,
useWithoutChangesIdentityOnly: true, useWithoutChangesIdentityOnly: true,
} as const; } as const;
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders"); expect(getNextStep("community-upload", opts)).toBe("final-review");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe( expect(getPreviousStep("final-review", opts)).toBe("community-upload");
"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( expect(
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
"confirm-stakeholders", "final-review",
undefined, undefined,
"mutual-aid-mondays", "mutual-aid-mondays",
), ),
@@ -138,10 +135,10 @@ describe("flowSteps", () => {
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" }); ).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( expect(
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
"confirm-stakeholders", "final-review",
{ useWithoutChangesIdentityOnly: true }, { useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays", "mutual-aid-mondays",
), ),
@@ -151,13 +148,13 @@ describe("flowSteps", () => {
it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => { it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => {
expect( expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined), resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined),
).toEqual({ kind: "step", step: "conflict-management" }); ).toEqual({ kind: "step", step: "final-review" });
}); });
it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => { it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => {
expect( expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "), resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "),
).toEqual({ kind: "step", step: "conflict-management" }); ).toEqual({ kind: "step", step: "final-review" });
}); });
it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => { it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => {
@@ -129,7 +129,7 @@ describe("useTemplateReviewActions", () => {
await result.current.handleUseWithoutChanges(); 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?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly"); expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.setupTraits).toEqual({ employer: true }); expect(applied?.setupTraits).toEqual({ employer: true });