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,
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)}
</Button>
) : null
}
@@ -38,7 +38,7 @@ const blurActiveElement = (): void => {
* still needs identity (direct from a template) walks name / description /
* photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* identity and land on `final-review`; Back from that step returns
* to the template instead of `conflict-management`.
*
* Template review footer Back uses {@link buildTemplateReviewHref}’s
@@ -43,7 +43,7 @@ export type UseTemplateReviewActionsResult = {
* `/create/community-name` with a pin so Next/Back collect only name →
* description → photo, and drops leftover setup answers along with the
* other identity fields. In-flow (`?fromFlow=1`, community steps already
* done) routes to `/create/confirm-stakeholders` and keeps those answers.
* done) routes to `/create/final-review` and keeps those answers.
*/
handleUseWithoutChanges: () => Promise<void>;
};
@@ -268,7 +268,7 @@ export function useTemplateReviewActions({
});
router.push(
fromCreateWizard
? createFlowStepPath("confirm-stakeholders")
? createFlowStepPath("final-review")
: createFlowStepPath("community-name"),
);
}, [
@@ -14,8 +14,11 @@ import {
} from "../../components/CreateFlowLockupCardStepShell";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { communityInitialsFromTitle } from "../../../../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../../../../lib/create/finalReviewChipWarnings";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type {
TemplateChipDetail,
@@ -32,10 +35,6 @@ import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisp
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import {
persistDraftPreviewSurfaceColor,
@@ -51,53 +50,17 @@ function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
return joinsWithoutAYes && conflict.includes("binding-arbitration");
}
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
* management). We reuse that ordering for the state-derived rows so the
* Rule layout stays stable across customize / use-without-changes /
* plain-custom flows, and fall back to the demo chips when state resolves
* to nothing selected.
*/
function readFallbackCategoryRows(
categories: readonly { name: string; chips: readonly string[] }[],
): {
names: {
values: string;
communication: string;
membership: string;
decisions: string;
conflict: string;
};
rows: FinalReviewCategoryRowDetailed[];
} {
function categoryNamesFromMessages(
categories: readonly { name: string }[],
): FinalReviewCategoryNames {
const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : "";
return {
names: {
values: get(0),
communication: get(1),
membership: get(2),
decisions: get(3),
conflict: get(4),
},
rows: categories.map((c) => ({
name: c.name,
groupKey: null,
entries: [...c.chips].map((label) => ({
label,
groupKey: null,
overrideKey: null,
})),
})),
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({
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
onTitleClick={
variant === "editPublished"
? () => setTitleModalOpen(true)
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={
variant === "editPublished"
? () => setCommunityContextModalOpen(true)
: undefined
}
onTitleClick={() => setTitleModalOpen(true)}
titleEditAriaLabel={t("titleEditModal.ariaEditTitle")}
onDescriptionClick={() => setCommunityContextModalOpen(true)}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={
variant === "editPublished"
? t("communityContextEditModal.ariaEditDescription")
: undefined
}
descriptionEditAriaLabel={t("communityContextEditModal.ariaEditDescription")}
size={mdUp ? "L" : "M"}
expanded={true}
descriptionAtBottom
backgroundColor="bg-transparent"
surfaceColor={surfaceColor}
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))}
logoUrl={avatarUrl}
logoAlt={ruleCardTitle}
communityInitials={
avatarUrl ? undefined : communityInitialsFromTitle(ruleCardTitle)
}
categories={finalReviewCategories}
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
onClick={() => {}}
@@ -352,28 +312,24 @@ export function FinalReviewScreen({
onClose={() => setActiveReadOnlyDetail(null)}
detail={activeReadOnlyDetail}
/>
{variant === "editPublished" ? (
<>
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
initialValue={rawTitleForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ title: value });
}}
/>
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
</>
) : null}
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
initialValue={rawTitleForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ title: value });
}}
/>
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
</CreateFlowLockupCardStepShell>
);
}
@@ -1,5 +1,10 @@
import type footerMessages from "../../../../messages/en/create/footer.json";
import type { CreateFlowStep } from "../types";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
} from "../../../../lib/create/buildFinalReviewCategories";
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
import type { CreateFlowState, CreateFlowStep } from "../types";
type FooterMessages = typeof footerMessages;
@@ -9,9 +14,10 @@ type FooterMessages = typeof footerMessages;
* fires `goToNextStep` for steps without a bespoke footer). Steps absent
* from this map fall back to `footer.next`.
*
* `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `publishCommunityRule`).
* `final-review` uses Continue (gated by {@link isFinalReviewContinueBlocked}).
* `edit-rule` is handled separately by the caller because its label also
* depends on the in-flight publish flag (`publishButtonPublishing` vs
* `publishCommunityRule`).
*/
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep,
@@ -53,10 +59,42 @@ export function isCommunitySizeStepBlocked(
return !hasNonEmptyId(selectedCommunitySizeIds);
}
const FINAL_REVIEW_BLOCK_NAMES: FinalReviewCategoryNames = {
values: "values",
communication: "communication",
membership: "membership",
decisions: "decisions",
conflict: "conflict",
};
const FINAL_REVIEW_REQUIRED_GROUPS: readonly TemplateFacetGroupKey[] = [
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* Continue on final review waits for a community name and at least one card
* on each of the four questions. Skipped values do not block.
*/
export function isFinalReviewContinueBlocked(state: CreateFlowState): boolean {
const title = typeof state.title === "string" ? state.title.trim() : "";
if (title.length === 0) return true;
const rows = buildFinalReviewCategoryRowsDetailed(
state,
FINAL_REVIEW_BLOCK_NAMES,
);
return FINAL_REVIEW_REQUIRED_GROUPS.some((groupKey) => {
const row = rows.find((candidate) => candidate.groupKey === groupKey);
return !row || row.entries.length === 0;
});
}
/**
* Resolve the localized label for the default "next-step" footer button.
* Returns the per-step override when one is registered, otherwise
* `footer.next`. Caller still owns the `final-review` special case.
* `footer.next`. Caller still owns the `final-review` / `edit-rule` special case.
*/
export function getDefaultFooterLabel(
step: CreateFlowStep | null | undefined,
@@ -21,8 +21,8 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // final-review
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // completed
] as const;
@@ -123,18 +123,18 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-213155",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
"edit-rule": {
layoutKind: "review",
figmaNodeId: "20907-212767",
+5 -5
View File
@@ -27,8 +27,8 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"membership-methods",
"decision-approaches",
"conflict-management",
"confirm-stakeholders",
"final-review",
"confirm-stakeholders",
"completed",
] as const;
@@ -61,7 +61,7 @@ export type CreateFlowNavigationOptions = {
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
* `community-upload`, then `final-review`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
@@ -131,9 +131,9 @@ export function getPreviousStep(
* Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* `final-review` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* from `final-review` returns to template review instead of
* `conflict-management`.
*/
export type CreateFlowBackTarget =
@@ -157,7 +157,7 @@ export function resolveCreateFlowBackTarget(
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
currentStep === "final-review" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
+4
View File
@@ -6,6 +6,10 @@ export interface Category {
chipOptions: ChipOption[];
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
addButton?: boolean;
/** Label on the add control. Empty string keeps the icon-only circle. */
addButtonText?: string;
/** Figma Has Flag: short line after the add chip in the same row. */
flag?: string;
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (
+2 -1
View File
@@ -472,7 +472,8 @@ export function RuleView({
addButton={
!hideCategoryAddButton && category.addButton !== false
}
addButtonText="" // Empty text for icon-only circular button
addButtonText={category.addButtonText ?? ""}
flag={category.flag}
className="w-full"
/>
))}
@@ -21,6 +21,7 @@ const ChipContainer = memo<ChipProps>(
onRemove,
onCheck,
onClose,
hasWarning = false,
ariaLabel,
}) => {
const t = useTranslation("controlsChrome");
@@ -89,11 +90,19 @@ const ChipContainer = memo<ChipProps>(
onRemove={onRemove}
onCheck={handleCheck}
onClose={handleClose}
hasWarning={hasWarning}
inputValue={isCustom ? inputValue : undefined}
onInputChange={isCustom ? setInputValue : undefined}
onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel ?? (isCustom ? undefined : label)}
ariaLabel={
ariaLabel ??
(isCustom
? undefined
: hasWarning
? t("chipMissingWords").replace("{label}", label)
: label)
}
confirmAriaLabel={t("chipConfirm")}
typeToAddPlaceholder={t("chipTypeToAdd")}
closeAriaLabel={t("chipClose")}
@@ -48,6 +48,11 @@ export interface ChipProps {
* Optional callback when custom chip is closed/removed.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Figma Has Warning: show a warning icon in place of the selected check.
* Used on final review when a card or value is missing its words.
*/
hasWarning?: boolean;
ariaLabel?: string;
}
@@ -62,6 +67,7 @@ export interface ChipViewProps {
onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCheck?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
hasWarning?: boolean;
inputValue?: string;
onInputChange?: (value: string) => void;
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
+9 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, type CSSProperties } from "react";
import Icon from "../../asset/icon/Icon";
import type { ChipViewProps } from "./Chip.types";
function chipSurfaceStyle(
@@ -87,6 +88,7 @@ function ChipView({
onRemove,
onCheck,
onClose,
hasWarning = false,
inputValue,
onInputChange,
onInputKeyDown,
@@ -280,7 +282,13 @@ function ChipView({
onClick={handleClick}
{...sharedA11y}
>
{isSelected ? (
{hasWarning ? (
<Icon
name="warning"
size={isSmall ? 12 : 16}
className="shrink-0 text-current"
/>
) : isSelected ? (
<svg
aria-hidden
viewBox="0 0 12 12"
@@ -20,6 +20,7 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick,
addButton: addButtonProp = true,
addButtonText,
flag,
formHeader = true,
helperText,
errorMessage,
@@ -45,11 +46,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick={onAddClick}
addButton={addButtonProp}
addButtonText={addButtonText}
flag={flag}
addButtonAriaLabel={
addButtonText || t("multiSelectAddFallback")
}
chipSelectAriaLabel={t("chipSelect")}
chipDeselectAriaLabel={t("chipDeselect")}
chipMissingWordsAriaLabel={t("chipMissingWords")}
formHeader={formHeader}
helperText={helperText}
errorMessage={errorMessage}
@@ -7,6 +7,8 @@ export interface ChipOption {
id: string;
label: string;
state?: ChipStateValue;
/** Figma Has Warning on Control / Chip. */
hasWarning?: boolean;
}
export type MultiSelectSizeValue = "s" | "m";
@@ -49,6 +51,11 @@ export interface MultiSelectProps {
* Text for the add button
*/
addButtonText?: string;
/**
* Figma Has Flag: short line after the add chip in the same row, with no
* fill and no extra height.
*/
flag?: string;
/**
* Whether to show form header (label and help icon) above multi-select (Figma prop).
* @default true
@@ -93,7 +100,9 @@ export interface MultiSelectViewProps {
onAddClick?: () => void;
addButton: boolean;
addButtonText?: string;
flag?: string;
addButtonAriaLabel: string;
chipMissingWordsAriaLabel: string;
chipSelectAriaLabel: string;
chipDeselectAriaLabel: string;
formHeader: boolean;
@@ -2,6 +2,7 @@
import { memo } from "react";
import Chip from "../Chip";
import Icon from "../../asset/icon/Icon";
import InputLabel from "../../type/InputLabel";
import type { MultiSelectViewProps } from "./MultiSelect.types";
@@ -15,9 +16,11 @@ function MultiSelectView({
onAddClick,
addButton,
addButtonText,
flag,
addButtonAriaLabel,
chipSelectAriaLabel,
chipDeselectAriaLabel,
chipMissingWordsAriaLabel,
formHeader = true,
helperText: helperTextProp,
errorMessage,
@@ -94,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({
)}
</button>
)}
{flag ? (
<span
className={`inline-flex items-center gap-[var(--space-100)] text-x-small-paragraph ${
isInverse
? "text-[color:var(--color-content-invert-primary)]"
: "text-[color:var(--color-content-default-primary)]"
}`}
>
<Icon
name="warning"
size={12}
className="shrink-0 text-current"
/>
{flag}
</span>
) : null}
</div>
{errorMessage ? (
<p