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:
1 parent
cc878e3c19
commit
6e2e1915ae
30 files changed
+571
-218
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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?: (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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"] },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
} 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 });
|
||||||
|
|||||||
Reference in new issue
Block a user