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