Template flow cleaned up
This commit is contained in:
@@ -34,11 +34,17 @@ import {
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { deleteServerDraft } from "../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
type RuleTemplateDto,
|
||||
} from "../../../lib/create/fetchTemplates";
|
||||
import { buildTemplateCustomizePrefill } from "../../../lib/create/applyTemplatePrefill";
|
||||
import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug";
|
||||
import messages from "../../../messages/en/index";
|
||||
import {
|
||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||
} from "./utils/createFlowFooterClassNames";
|
||||
import {
|
||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||
type CustomRuleConfirmFooterStep,
|
||||
} from "./utils/customRuleConfirmFooterSteps";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
@@ -114,7 +120,8 @@ function CreateFlowLayoutContent({
|
||||
} = useCreateFlowNavigation(
|
||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||
);
|
||||
const { state, clearState, updateState } = useCreateFlow();
|
||||
const { state, clearState, updateState, resetCustomRuleSelections } =
|
||||
useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
@@ -188,38 +195,139 @@ function CreateFlowLayoutContent({
|
||||
);
|
||||
}, [state, router, openLogin]);
|
||||
|
||||
const handleUseTemplateWithoutChanges = useCallback(async () => {
|
||||
/**
|
||||
* Customize flow from a template-review page. Applies the template's
|
||||
* customize selections onto `CreateFlowState` so the custom-rule screens
|
||||
* render with chips pre-highlighted, then routes to `core-values` once
|
||||
* the community name is set — otherwise to `informational` with a
|
||||
* `pendingTemplateAction` pin so `/create/review` later redirects past
|
||||
* itself straight to `core-values` (see `CommunityReviewScreen`).
|
||||
*
|
||||
* Why title alone? Other community-stage fields (e.g.
|
||||
* `communityStructureChipSnapshots`) are sticky once the user lands on
|
||||
* those screens, so they can't reliably answer "has the user given us
|
||||
* real input yet?". A non-empty community name is the minimum bar
|
||||
* `buildPublishPayload` already enforces — we reuse that here.
|
||||
*
|
||||
* Direct entry (marketing home "Popular templates" or `/templates`
|
||||
* landed on directly) wipes the anonymous draft at the *click site* via
|
||||
* `clearCreateFlowPersistedDrafts` before navigating, so `state.title`
|
||||
* is empty here and the no-community branch fires naturally. No
|
||||
* URL-marker plumbing needed in this handler.
|
||||
*/
|
||||
const handleCustomizeTemplate = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
setTemplateReviewApplyError(null);
|
||||
setIsApplyingTemplate(true);
|
||||
const result = await fetchTemplateBySlug(templateReviewSlug);
|
||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||
setIsApplyingTemplate(false);
|
||||
if (result === null) {
|
||||
setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
|
||||
if (loaded.ok === false) {
|
||||
setTemplateReviewApplyError(loaded.message);
|
||||
return;
|
||||
}
|
||||
if ("error" in result) {
|
||||
setTemplateReviewApplyError(result.error);
|
||||
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
// Prefill merges (shallow) with current state. When we have to bounce the
|
||||
// user to the community stage first, pin a pendingTemplateAction so
|
||||
// `/create/review` knows to skip past itself to `core-values` later.
|
||||
updateState({
|
||||
...prefill,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "customize",
|
||||
},
|
||||
}),
|
||||
});
|
||||
router.push(
|
||||
hasCommunityName ? "/create/core-values" : "/create/informational",
|
||||
);
|
||||
}, [router, state.title, templateReviewSlug, updateState]);
|
||||
|
||||
/**
|
||||
* "Use without changes" from a template-review page. Drops users into the
|
||||
* review-and-complete stage (`confirm-stakeholders` → `final-review`) so the
|
||||
* publish flow — and its server-enforced sign-in gate (`publishRule` 401 →
|
||||
* `openLogin`) — is reused. The template body becomes the rule document;
|
||||
* the user's community name remains the rule title.
|
||||
*
|
||||
* Community-name branch: apply template body/summary immediately and jump
|
||||
* to `confirm-stakeholders`.
|
||||
*
|
||||
* No-community-name branch: same template body/summary apply so state is
|
||||
* ready, plus a `pendingTemplateAction` pin so `/create/review` later
|
||||
* redirects past itself straight to `confirm-stakeholders` once community
|
||||
* data is captured (see `CommunityReviewScreen`). Users aren't forced back
|
||||
* through the template picker just to pick the same template again.
|
||||
*
|
||||
* Direct entry (marketing home "Popular templates" or `/templates`
|
||||
* landed on directly) wipes the anonymous draft at the click site via
|
||||
* `clearCreateFlowPersistedDrafts` before navigating, so `state.title`
|
||||
* is empty and the no-community branch fires naturally.
|
||||
*/
|
||||
const handleUseTemplateWithoutChanges = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
setTemplateReviewApplyError(null);
|
||||
|
||||
setIsApplyingTemplate(true);
|
||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||
setIsApplyingTemplate(false);
|
||||
if (loaded.ok === false) {
|
||||
setTemplateReviewApplyError(loaded.message);
|
||||
return;
|
||||
}
|
||||
const template: RuleTemplateDto = result;
|
||||
const { template } = loaded;
|
||||
const doc = template.body;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
|
||||
return;
|
||||
}
|
||||
const sectionsRaw = (doc as { sections?: unknown }).sections;
|
||||
const sections = Array.isArray(sectionsRaw)
|
||||
? (sectionsRaw as Record<string, unknown>[])
|
||||
: [];
|
||||
if (sections.length === 0) {
|
||||
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Using the template verbatim: scrub any prior customize picks so they
|
||||
// don't bleed into `document.coreValues` at publish time.
|
||||
resetCustomRuleSelections();
|
||||
|
||||
const summaryRaw =
|
||||
typeof template.description === "string"
|
||||
? template.description.trim()
|
||||
: "";
|
||||
writeLastPublishedRule({
|
||||
id: `template:${template.slug}`,
|
||||
title: template.title,
|
||||
summary: summaryRaw.length > 0 ? summaryRaw : null,
|
||||
document: doc as Record<string, unknown>,
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
sections,
|
||||
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "useWithoutChanges",
|
||||
},
|
||||
}),
|
||||
});
|
||||
router.push("/create/completed");
|
||||
}, [router, templateReviewSlug]);
|
||||
router.push(
|
||||
hasCommunityName
|
||||
? "/create/confirm-stakeholders"
|
||||
: "/create/informational",
|
||||
);
|
||||
}, [
|
||||
resetCustomRuleSelections,
|
||||
router,
|
||||
state.title,
|
||||
templateReviewSlug,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
@@ -360,8 +468,16 @@ function CreateFlowLayoutContent({
|
||||
currentStep,
|
||||
);
|
||||
|
||||
const footerPrimaryButtonClass =
|
||||
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
||||
/**
|
||||
* Custom Rule stage "confirm selection" steps: all five render the same
|
||||
* primary footer button, differing only by disable predicate and label.
|
||||
* Driving JSX from a config keeps the five sites aligned — adding a new
|
||||
* selection screen means one row here, not a new branch below.
|
||||
*/
|
||||
const customRuleConfirmFooter: CustomRuleConfirmFooterStep | undefined =
|
||||
currentStep != null
|
||||
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
||||
: undefined;
|
||||
|
||||
const hasTopOverlays =
|
||||
Boolean(draftSaveBannerMessage) ||
|
||||
@@ -483,7 +599,7 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isApplyingTemplate}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
|
||||
onClick={() => void handleUseTemplateWithoutChanges()}
|
||||
>
|
||||
{messages.create.templateReview.footer.useWithoutChanges}
|
||||
@@ -493,17 +609,8 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isApplyingTemplate}
|
||||
title={
|
||||
messages.create.templateReview.footer.customizeAriaHint
|
||||
}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
onClick={() => {
|
||||
if (!templateReviewSlug) return;
|
||||
// Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
|
||||
router.push(
|
||||
`/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
|
||||
);
|
||||
}}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => void handleCustomizeTemplate()}
|
||||
>
|
||||
{messages.create.templateReview.footer.customize}
|
||||
</Button>
|
||||
@@ -513,8 +620,12 @@ function CreateFlowLayoutContent({
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
disabled={
|
||||
isPublishing ||
|
||||
typeof state.title !== "string" ||
|
||||
state.title.trim().length === 0
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
@@ -528,7 +639,7 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
@@ -545,7 +656,7 @@ function CreateFlowLayoutContent({
|
||||
communitySaveMagicLinkSuccess ||
|
||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
void handleCommunitySaveMagicLinkSubmit();
|
||||
}}
|
||||
@@ -562,8 +673,11 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
// Scrub any prior template-customize prefill so entering
|
||||
// the custom-rule stage from review is always a clean slate.
|
||||
resetCustomRuleSelections();
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
@@ -574,93 +688,35 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
router.push("/templates");
|
||||
// `fromFlow=1` tells `/templates` to skip the fresh-slate
|
||||
// draft clear it normally runs on template click, so the
|
||||
// user's in-progress Create Community stage survives this
|
||||
// detour. Direct entries to `/templates` (no marker) and
|
||||
// home "Popular templates" clicks always start fresh by
|
||||
// wiping anonymous draft storage at click time.
|
||||
router.push("/templates?fromFlow=1");
|
||||
}}
|
||||
>
|
||||
{footer.createFromTemplate}
|
||||
</Button>
|
||||
</div>
|
||||
) : currentStep === "core-values" && nextStep ? (
|
||||
) : customRuleConfirmFooter && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedCoreValueIds?.length ?? 0) === 0
|
||||
customRuleConfirmFooter.selectionIds(state).length === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmCoreValues}
|
||||
</Button>
|
||||
) : currentStep === "communication-methods" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedCommunicationMethodIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmCommunication}
|
||||
</Button>
|
||||
) : currentStep === "membership-methods" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedMembershipMethodIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmMembership}
|
||||
</Button>
|
||||
) : currentStep === "decision-approaches" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedDecisionApproachIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmDecisionApproaches}
|
||||
</Button>
|
||||
) : currentStep === "conflict-management" && nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
(state.selectedConflictManagementIds?.length ?? 0) === 0
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmConflictManagement}
|
||||
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||
</Button>
|
||||
) : nextStep ? (
|
||||
<Button
|
||||
@@ -668,7 +724,7 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
if (currentStep === "final-review") {
|
||||
void handleFinalize();
|
||||
|
||||
@@ -167,12 +167,34 @@ export function CreateFlowProvider({
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
}, []);
|
||||
|
||||
// Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`.
|
||||
// Kept in sync with `CreateFlowState` comments marked "Create Custom —".
|
||||
const resetCustomRuleSelections = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const {
|
||||
selectedCoreValueIds: _a,
|
||||
coreValuesChipsSnapshot: _b,
|
||||
coreValueDetailsByChipId: _c,
|
||||
selectedCommunicationMethodIds: _d,
|
||||
selectedMembershipMethodIds: _e,
|
||||
selectedDecisionApproachIds: _f,
|
||||
selectedConflictManagementIds: _g,
|
||||
...rest
|
||||
} = prev;
|
||||
return rest;
|
||||
});
|
||||
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
|
||||
// localStorage key when the field goes undefined, so we don't need to
|
||||
// touch `clearCoreValueDetailsLocalStorage()` directly here.
|
||||
}, []);
|
||||
|
||||
const contextValue: CreateFlowContextValue = {
|
||||
state,
|
||||
currentStep,
|
||||
updateState,
|
||||
replaceState,
|
||||
clearState,
|
||||
resetCustomRuleSelections,
|
||||
interactionTouched,
|
||||
markCreateFlowInteraction,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import RuleCard from "../../../../components/cards/RuleCard";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
@@ -11,21 +13,65 @@ import {
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
/**
|
||||
* Targets for a `pendingTemplateAction` redirect. Customize resumes the
|
||||
* custom-rule stage with chips already prefilled; useWithoutChanges jumps to
|
||||
* the review-and-complete stage since the template body is already in state.
|
||||
*/
|
||||
const PENDING_TEMPLATE_REDIRECT_TARGET: Record<
|
||||
"customize" | "useWithoutChanges",
|
||||
string
|
||||
> = {
|
||||
customize: "/create/core-values",
|
||||
useWithoutChanges: "/create/confirm-stakeholders",
|
||||
};
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
const router = useRouter();
|
||||
const lgUp = useCreateFlowLgUp();
|
||||
const t = useTranslation("create.community.review");
|
||||
const { state } = useCreateFlow();
|
||||
const { state, updateState } = useCreateFlow();
|
||||
|
||||
/**
|
||||
* If the user picked "Customize" or "Use without changes" from a template
|
||||
* before entering community stage, we pinned `pendingTemplateAction` so
|
||||
* this screen can skip itself — they already expressed their intent, no
|
||||
* reason to make them re-pick from the review footer. We `replace` (not
|
||||
* `push`) so Back from the destination goes to `community-save` instead of
|
||||
* bouncing through here again. The action is cleared synchronously via
|
||||
* `updateState` to guarantee the redirect only fires once: later visits to
|
||||
* `/create/review` (e.g. navigating here directly) render normally.
|
||||
*
|
||||
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
|
||||
* fire `router.replace` twice on the same transition.
|
||||
*/
|
||||
const firedRedirectRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (firedRedirectRef.current) return;
|
||||
const pending = state.pendingTemplateAction;
|
||||
if (!pending) return;
|
||||
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
|
||||
if (!target) return;
|
||||
firedRedirectRef.current = true;
|
||||
updateState({ pendingTemplateAction: undefined });
|
||||
router.replace(target);
|
||||
}, [router, state.pendingTemplateAction, updateState]);
|
||||
|
||||
const cardTitle =
|
||||
typeof state.title === "string" && state.title.trim().length > 0
|
||||
? state.title.trim()
|
||||
: t("ruleCard.title");
|
||||
/**
|
||||
* No placeholder fallback: if the user skipped `community-context`, leave
|
||||
* the card description off rather than render the old "Mutual Aid Monday
|
||||
* is a grassroots community…" sample, which read as real user copy.
|
||||
*/
|
||||
const cardDescription =
|
||||
typeof state.communityContext === "string" &&
|
||||
state.communityContext.trim().length > 0
|
||||
? state.communityContext.trim()
|
||||
: t("ruleCard.description");
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../../components/CreateFlowLockupCardStepShell";
|
||||
import {
|
||||
buildFinalReviewCategoriesFromState,
|
||||
type FinalReviewCategoryRow,
|
||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||
|
||||
function buildFinalReviewCategories(
|
||||
rows: { name: string; chips: string[] }[],
|
||||
rows: readonly FinalReviewCategoryRow[],
|
||||
): Category[] {
|
||||
return rows.map((cat) => ({
|
||||
name: cat.name,
|
||||
@@ -24,16 +28,58 @@ function buildFinalReviewCategories(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `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
|
||||
* RuleCard 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 readFallbackCategoryNames(
|
||||
categories: readonly { name: string; chips: readonly string[] }[],
|
||||
): {
|
||||
names: {
|
||||
values: string;
|
||||
communication: string;
|
||||
membership: string;
|
||||
decisions: string;
|
||||
conflict: string;
|
||||
};
|
||||
rows: FinalReviewCategoryRow[];
|
||||
} {
|
||||
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, chips: [...c.chips] })),
|
||||
};
|
||||
}
|
||||
|
||||
export function FinalReviewScreen() {
|
||||
const { state } = useCreateFlow();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||
const m = useMessages();
|
||||
|
||||
const finalReviewCategories = useMemo(
|
||||
() => buildFinalReviewCategories(m.create.reviewAndComplete.finalReview.categories),
|
||||
[m.create.reviewAndComplete.finalReview.categories],
|
||||
);
|
||||
const finalReviewCategories = useMemo(() => {
|
||||
const { names, rows: fallbackRows } = readFallbackCategoryNames(
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
);
|
||||
const derived = buildFinalReviewCategoriesFromState(state, names);
|
||||
// When a user lands on final review with nothing actually selected (e.g.
|
||||
// direct-nav during dev), keep the shipped demo chips rather than render
|
||||
// an empty card — matches prior behavior for that edge case.
|
||||
return buildFinalReviewCategories(
|
||||
derived.length > 0 ? derived : fallbackRows,
|
||||
);
|
||||
}, [m.create.reviewAndComplete.finalReview.categories, state]);
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
|
||||
+28
-23
@@ -93,6 +93,18 @@ export interface CreateFlowState {
|
||||
selectedDecisionApproachIds?: string[];
|
||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||
selectedConflictManagementIds?: string[];
|
||||
/**
|
||||
* Set when a user picks a template (Customize or Use without changes) before
|
||||
* completing the community stage. The community-review screen consumes this
|
||||
* to `router.replace` past `/create/review` to the correct downstream step
|
||||
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes)
|
||||
* once community data is captured. Cleared the moment the redirect fires, so
|
||||
* later visits to `/create/review` render normally.
|
||||
*/
|
||||
pendingTemplateAction?: {
|
||||
slug: string;
|
||||
mode: "customize" | "useWithoutChanges";
|
||||
};
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
@@ -115,30 +127,23 @@ export interface CreateFlowContextValue {
|
||||
/** Reset flow state and clear anonymous localStorage draft keys when present. */
|
||||
clearState: () => void;
|
||||
/**
|
||||
* True after the user edits any template control (pages use local state until wired to `state`).
|
||||
* Drives Save & Exit visibility together with hasCreateFlowUserInput (utils/hasCreateFlowUserInput.ts).
|
||||
* Scrub only the Create Custom stage selections (core values, communication,
|
||||
* membership, decision approaches, conflict management) from state. Keeps
|
||||
* the community stage (title, context, size, structure) intact so users can
|
||||
* re-enter the custom-rule flow from `/create/review` with a clean slate
|
||||
* after a prior "Customize template" prefill.
|
||||
*/
|
||||
resetCustomRuleSelections: () => void;
|
||||
/**
|
||||
* True after the user has edited any control inside the wizard. Screens flip
|
||||
* it via {@link markCreateFlowInteraction} from their event handlers.
|
||||
*
|
||||
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
||||
* has already started editing, we skip replaying their server draft on top
|
||||
* of in-progress local state. Save & Exit visibility is driven by step
|
||||
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
|
||||
* flag.
|
||||
*/
|
||||
interactionTouched: boolean;
|
||||
markCreateFlowInteraction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base props interface for page templates
|
||||
* Will be expanded in template implementation tickets (CR-51-55)
|
||||
*/
|
||||
export interface PageTemplateProps {
|
||||
// Base props for all page templates
|
||||
// Will be expanded in template tickets
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation handlers interface
|
||||
* Will be implemented in CR-56
|
||||
*/
|
||||
export interface NavigationHandlers {
|
||||
goToNextStep: () => void;
|
||||
goToPreviousStep: () => void;
|
||||
goToStep: (_step: CreateFlowStep) => void;
|
||||
canGoNext: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
|
||||
|
||||
/**
|
||||
* Wipe the anonymous in-progress create-flow draft from `localStorage` (both
|
||||
* the main `create-flow-anonymous` blob and the separate core-value details
|
||||
* key). Intended for call sites that navigate **into** the create flow from
|
||||
* outside and want a fresh slate — today that's the marketing "Popular
|
||||
* templates" click handler on the home page and the `/templates` index page
|
||||
* (when not in-flow). `CreateFlowProvider` reads `localStorage` during its
|
||||
* `useState` initializer, so clearing *before* pushing the next route means
|
||||
* the provider mounts empty and the Create Community stage starts clean.
|
||||
*
|
||||
* Note: this only touches localStorage. It does **not** delete the
|
||||
* authenticated user's server draft (`/api/drafts/me`). Server drafts are
|
||||
* loaded deliberately from the profile page, not re-hydrated into the flow
|
||||
* on every entry, so there's nothing to wipe here for signed-in users.
|
||||
*/
|
||||
export function clearCreateFlowPersistedDrafts(): void {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Typography + padding overrides applied to the primary/secondary buttons
|
||||
* rendered inside `CreateFlowFooter`. The footer slot expects a compact
|
||||
* size regardless of the default `<Button size="xsmall">` output, and both
|
||||
* the Create Community / Custom Rule / Review flows and the template-review
|
||||
* footer share the same override string — keeping it here prevents drift
|
||||
* between those two call sites.
|
||||
*
|
||||
* The `!` prefixes bypass Button's own size tokens; the extra spacing vars
|
||||
* mirror the Figma compact footer button spec. When the design system
|
||||
* exposes a native size that matches, this module should collapse.
|
||||
*/
|
||||
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
|
||||
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] " +
|
||||
"!px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] " +
|
||||
"!py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
||||
|
||||
/**
|
||||
* Template-review "Use without changes" (ghost variant) renders on a dark
|
||||
* backdrop and needs an explicit text-color override in addition to the
|
||||
* shared compact sizing. Composed from the base class so any future tweak
|
||||
* to typography/padding propagates automatically.
|
||||
*/
|
||||
export const CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS = `${CREATE_FLOW_FOOTER_BUTTON_CLASS} !text-white`;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import type footerMessages from "../../../../messages/en/create/footer.json";
|
||||
|
||||
type FooterMessageKey = keyof typeof footerMessages;
|
||||
|
||||
/**
|
||||
* Binding for each Custom Rule stage step whose footer primary button
|
||||
* gates the user on "has at least one chip selected?". All five screens
|
||||
* render the same `<Button …>`; only the disable predicate and the
|
||||
* footer message differ — this table is the single source of truth for
|
||||
* both, so `CreateFlowLayoutClient` can render one JSX block for the
|
||||
* whole group.
|
||||
*
|
||||
* `selectionIds` returns the currently-selected ids array from flow
|
||||
* state for that step (empty array when nothing has been selected or
|
||||
* the field hasn't been touched). Returning a fresh array on empty is
|
||||
* fine: these are read-only length checks, not memo keys.
|
||||
*
|
||||
* Note: the Confirm Stakeholders step has its own dedicated label copy
|
||||
* and is not gated on a selection count, so it stays out of this table.
|
||||
* Template-review and Community Save also have bespoke two-button
|
||||
* layouts and are intentionally excluded.
|
||||
*/
|
||||
export type CustomRuleConfirmFooterStep = {
|
||||
step: Extract<
|
||||
CreateFlowStep,
|
||||
| "core-values"
|
||||
| "communication-methods"
|
||||
| "membership-methods"
|
||||
| "decision-approaches"
|
||||
| "conflict-management"
|
||||
>;
|
||||
footerMessageKey: FooterMessageKey;
|
||||
selectionIds: (state: CreateFlowState) => readonly string[];
|
||||
};
|
||||
|
||||
export const CUSTOM_RULE_CONFIRM_FOOTER_STEPS: readonly CustomRuleConfirmFooterStep[] =
|
||||
[
|
||||
{
|
||||
step: "core-values",
|
||||
footerMessageKey: "confirmCoreValues",
|
||||
selectionIds: (s) => s.selectedCoreValueIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "communication-methods",
|
||||
footerMessageKey: "confirmCommunication",
|
||||
selectionIds: (s) => s.selectedCommunicationMethodIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "membership-methods",
|
||||
footerMessageKey: "confirmMembership",
|
||||
selectionIds: (s) => s.selectedMembershipMethodIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "decision-approaches",
|
||||
footerMessageKey: "confirmDecisionApproaches",
|
||||
selectionIds: (s) => s.selectedDecisionApproachIds ?? [],
|
||||
},
|
||||
{
|
||||
step: "conflict-management",
|
||||
footerMessageKey: "confirmConflictManagement",
|
||||
selectionIds: (s) => s.selectedConflictManagementIds ?? [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||
CreateFlowStep,
|
||||
CustomRuleConfirmFooterStep
|
||||
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
|
||||
const IGNORED_KEYS = new Set<string>(["currentStep"]);
|
||||
|
||||
function valueIndicatesUserInput(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value as object).length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the user has entered meaningful create-flow data (not only navigation metadata).
|
||||
* Used to show "Save & Exit" vs a plain "Exit" that confirms data loss.
|
||||
*/
|
||||
export function hasCreateFlowUserInput(state: CreateFlowState): boolean {
|
||||
for (const key of Object.keys(state)) {
|
||||
if (IGNORED_KEYS.has(key)) continue;
|
||||
if (valueIndicatesUserInput(state[key])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
export interface TemplatesPageClientProps {
|
||||
@@ -17,7 +19,6 @@ export interface TemplatesPageClientProps {
|
||||
export default function TemplatesPageClient({
|
||||
initialGridEntries,
|
||||
}: TemplatesPageClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.templates");
|
||||
|
||||
return (
|
||||
@@ -39,16 +40,56 @@ export default function TemplatesPageClient({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 min-[1024px]:mt-8">
|
||||
<GovernanceTemplateGrid
|
||||
entries={initialGridEntries}
|
||||
onTemplateClick={(slug) => {
|
||||
router.push(
|
||||
`/create/review-template/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* Suspense boundary required by `useSearchParams` below
|
||||
(Next.js 15+ static-generation contract). */}
|
||||
<Suspense
|
||||
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
||||
>
|
||||
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when
|
||||
* the user arrived from `/create/review`'s "Create from template" button.
|
||||
* That button pushes `/templates?fromFlow=1` so their in-progress community
|
||||
* stage is preserved when they pick a template here.
|
||||
*/
|
||||
function TemplatesGridWithSearchParams({
|
||||
entries,
|
||||
}: {
|
||||
entries: TemplateGridCardEntry[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const fromFlow = searchParams.get("fromFlow") === "1";
|
||||
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
||||
}
|
||||
|
||||
function TemplatesGrid({
|
||||
entries,
|
||||
fromFlow,
|
||||
}: {
|
||||
entries: TemplateGridCardEntry[];
|
||||
fromFlow: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<GovernanceTemplateGrid
|
||||
entries={entries}
|
||||
onTemplateClick={(slug) => {
|
||||
if (!fromFlow) {
|
||||
// Direct entry to `/templates`: treat template click as a fresh
|
||||
// create-flow start and wipe any stale anonymous draft before
|
||||
// navigating. In-flow entry (`?fromFlow=1`) skips the clear so
|
||||
// the user's community stage survives the detour through here.
|
||||
clearCreateFlowPersistedDrafts();
|
||||
}
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthModal } from "../../../contexts/AuthModalContext";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
@@ -9,6 +9,8 @@ import Button from "../../buttons/Button";
|
||||
import AvatarContainer from "../../utility/AvatarContainer";
|
||||
import Avatar from "../../icons/Avatar";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import { clearAnonymousCreateFlowStorage } from "../../../(app)/create/utils/anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "../../../(app)/create/utils/coreValueDetailsLocalStorage";
|
||||
import { TopNavView } from "./TopNav.view";
|
||||
import type { TopNavProps, NavSize } from "./TopNav.types";
|
||||
|
||||
@@ -25,6 +27,20 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
const { openLogin } = useAuthModal();
|
||||
const t = useTranslation("header");
|
||||
|
||||
/**
|
||||
* TopNav is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||
* this button is always clicked from outside the wizard — there is no
|
||||
* mounted CreateFlowProvider to reset. Wiping the anonymous draft keys
|
||||
* here guarantees a fresh start; the provider that mounts on `/create`
|
||||
* will read empty storage. Server drafts (signed-in Save & Exit) are
|
||||
* left alone — they're intentional persistence the user opted into.
|
||||
*/
|
||||
const handleCreateRuleClick = useCallback(() => {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
router.push("/create");
|
||||
}, [router]);
|
||||
|
||||
// Schema markup for site navigation
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -197,7 +213,7 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
size={buttonSize}
|
||||
buttonType={buttonType}
|
||||
palette={palette}
|
||||
onClick={() => router.push("/create")}
|
||||
onClick={handleCreateRuleClick}
|
||||
ariaLabel={t("ariaLabels.createNewRule")}
|
||||
>
|
||||
{renderAvatarGroup(containerSize, avatarSize)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { clearCreateFlowPersistedDrafts } from "../../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
||||
import {
|
||||
fetchTemplates,
|
||||
isTemplatesFetchAborted,
|
||||
@@ -89,6 +90,11 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
}
|
||||
}
|
||||
logger.debug(`${slug} template clicked`);
|
||||
// Marketing entry is always a *fresh* create-flow start: wipe any
|
||||
// in-progress anonymous draft so a stale community name/structure from
|
||||
// an earlier abandoned session can't short-circuit the `state.title`
|
||||
// check in `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges`.
|
||||
clearCreateFlowPersistedDrafts();
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
};
|
||||
|
||||
|
||||
+21
-1
@@ -55,7 +55,27 @@ Active step for chrome and navigation is resolved from the pathname via [`parseC
|
||||
| --- | --- |
|
||||
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
|
||||
|
||||
From that page, **Customize** currently navigates to `/create/informational?template=<slug>`. The **`template` query parameter is reserved**; the informational step **does not** yet read it to prefill `CreateFlowState`. **Starting the wizard from a template at `final-review` or any mid-flow step** is **out of scope** until a dedicated product ticket ships. A **full create-from-template** experience will **likely use separate route(s)** when product and eng define it (may still align conceptually with the same three stages where behavior overlaps the custom path).
|
||||
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
||||
|
||||
**Use without changes** writes the template's `body.sections` into `state.sections` (and its `description` into `state.summary` when present), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline, which gates unauthenticated publishes with a **401 → `openLogin`** redirect back to `/create/final-review?syncDraft=1`.
|
||||
|
||||
**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` + `summary` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. 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 past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally.
|
||||
|
||||
**Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page, using [`clearCreateFlowPersistedDrafts`](../app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts) — a tiny helper that wipes the anonymous draft from `localStorage` (both `create-flow-anonymous` and the core-value-details key) **before** the navigation fires. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, the provider mounts empty and `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges` naturally take the no-community branch — no per-handler marker plumbing needed.
|
||||
|
||||
| Origin | Click-site behavior | URL the user lands on |
|
||||
| --- | --- | --- |
|
||||
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` |
|
||||
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` |
|
||||
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → skips the clear | `/create/review-template/[slug]` |
|
||||
|
||||
Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templates`). It is not forwarded onto the review-template URL. The review-template handlers branch solely on `state.title` — they don't need to know the origin.
|
||||
|
||||
Server drafts (`/api/drafts/me`) are **not** touched here. Per product plan they are not auto-hydrated into the create flow; users select and load a specific saved draft from the profile page. So wiping `localStorage` is sufficient for the "fresh slate" invariant.
|
||||
|
||||
**Final-review RuleCard category chips** are derived from `CreateFlowState` via [`buildFinalReviewCategoriesFromState`](../lib/create/buildFinalReviewCategories.ts): for the Customize / plain custom-rule path it resolves `selected{Communication,Membership,DecisionApproach,ConflictManagement}MethodIds` against the curated method presets in `messages/en/create/customRule/*.json`, and `buildCoreValuesForDocument` supplies the `Values` row from `coreValuesChipsSnapshot` + `selectedCoreValueIds`. For the Use-without-changes path the template body lives in `state.sections`; the helper renders `categoryName` + entry titles directly. The demo chips shipped in `finalReview.json` remain the fallback only when nothing in state resolves to any chip (e.g. direct navigation for development).
|
||||
|
||||
**Starting the wizard from a template at `final-review` directly** is out of scope until a dedicated product ticket ships. A **full create-from-template** experience will **likely use separate route(s)** when product and eng define it (may still align conceptually with the same three stages where behavior overlaps the custom path).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Establish the **official custom** create-rule flow (ordered steps, URLs, persistence, entry points, **Figma three-stage framing**) in repo docs and close gaps between that spec and the implementation (routing clutter, progress UI, step source of truth, resume vs URL).
|
||||
|
||||
**Context:** Step order lives in [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts). Wizard screens render from [`app/(app)/create/[screenId]/page.tsx`](app/(app)/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` or mid-wizard prefill** is **out of scope** here (future ticket); `/create/informational?template=` is a **no-op** until then.
|
||||
**Context:** Step order lives in [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts). Wizard screens render from [`app/(app)/create/[screenId]/page.tsx`](app/(app)/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/(app)/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` prefill** (skipping straight to publish) is **out of scope** here (future ticket). The **Customize** button on `/create/review-template/[slug]` now prefills customize selections and routes to `core-values` (or `informational` when Community is empty) via [`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts). **Use without changes** writes `template.body.sections` into `state.sections` and routes to `confirm-stakeholders`, so the user exits through the normal `final-review → handleFinalize → publishRule` pipeline and inherits its 401 sign-in gate. When either button is clicked **before** the community stage is done, the handler still applies its side effects eagerly and pins a `pendingTemplateAction: { slug, mode }` on `CreateFlowState`; [`CommunityReviewScreen`](app/(app)/create/screens/review/CommunityReviewScreen.tsx) consumes the pin on mount and `router.replace`s past itself to the right downstream step (`core-values` for customize, `confirm-stakeholders` for useWithoutChanges), so users never see the community-review page after expressing template intent at the template-review step.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
@@ -328,7 +328,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [ ] No misleading dynamic step placeholder for valid wizard URLs.
|
||||
- [ ] Footer progress reflects step index **or** doc/issue records a deliberate deferral with design sign-off.
|
||||
- [ ] Hydration + `currentStep` behavior is verified (redirect vs stay).
|
||||
- [ ] `?template=` documented as deferred; no implied “template customize → full wizard” parity.
|
||||
- [ ] Template **Customize** prefill is documented (maps template body to `selected*Ids` + `coreValuesChipsSnapshot`, routes to `core-values` when Community has data else `informational`); full template-customize-from-mid-wizard entry beyond `core-values` stays deferred.
|
||||
|
||||
**Files:** [`docs/create-flow.md`](create-flow.md), [`app/(app)/create/`](app/(app)/create/), [`app/components/utility/CreateFlowFooter/`](app/components/utility/CreateFlowFooter/), optionally [`docs/backend-roadmap.md`](backend-roadmap.md) §12 cross-links.
|
||||
|
||||
|
||||
@@ -269,9 +269,27 @@ decision-approaches → conflict-management → confirm-stakeholders → final-r
|
||||
| API list | `app/api/templates/route.ts` (GET only, no params today) |
|
||||
|
||||
Template ranking adds optional facet query params to `/api/templates`;
|
||||
the no-facets path keeps today's curated ordering. The
|
||||
`/create/informational?template=<slug>` query-param prefill is a known
|
||||
no-op (`CreateFlowLayoutClient.tsx`); fixing it is **out of scope**.
|
||||
the no-facets path keeps today's curated ordering. Template **Customize**
|
||||
now prefills the custom-rule flow via
|
||||
[`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts)
|
||||
(applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values`
|
||||
when Community already has input, else to `informational`. Template **Use
|
||||
without changes** writes `template.body.sections` into `state.sections`
|
||||
and routes to `confirm-stakeholders`, so the user exits via the normal
|
||||
`final-review → handleFinalize → publishRule` path and picks up the
|
||||
server-enforced 401 sign-in gate for free.
|
||||
|
||||
When the user picks a template **before** completing the community
|
||||
stage, both handlers still apply their side effects eagerly (prefill or
|
||||
`sections`/`summary`) and pin a
|
||||
`pendingTemplateAction: { slug, mode: "customize" | "useWithoutChanges" }`
|
||||
on `CreateFlowState`, then route to `informational`. Once the user
|
||||
reaches `/create/review`, `CommunityReviewScreen` consumes the pin and
|
||||
`router.replace`s past itself — to `core-values` for `customize`, to
|
||||
`confirm-stakeholders` for `useWithoutChanges`. The community-review
|
||||
screen is therefore only shown when the user came from "Create Custom"
|
||||
(no template), matching the intent already expressed at the
|
||||
template-review step.
|
||||
|
||||
---
|
||||
|
||||
@@ -660,10 +678,12 @@ Once the API exists:
|
||||
rendering.
|
||||
- API failure or empty facets → render the messages deck in its on-disk
|
||||
order. No regression from today.
|
||||
- Selecting a template on the marketing home or `templates/` page can
|
||||
prefill the create flow's `selected*MethodIds` from the template's
|
||||
composition (closes the `?template=` no-op gap noted in
|
||||
`CreateFlowLayoutClient.tsx`). Out of scope for CR-88.
|
||||
- Selecting a template on the template-review page via **Customize**
|
||||
prefills the create flow's `selected*MethodIds` and core-values chip
|
||||
snapshot from the template's composition — see
|
||||
[`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts)
|
||||
and the `handleCustomizeTemplate` handler in
|
||||
`CreateFlowLayoutClient.tsx`. Shipped outside CR-88.
|
||||
- Recommendations **never hide** options — ranking only. Authors expect
|
||||
to see "all 32 decision-making patterns" with the matching ones
|
||||
surfaced first.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
import { methodSlugFromTitle } from "./methodSlugFromTitle";
|
||||
|
||||
type TemplateEntry = { title: unknown };
|
||||
type TemplateSection = { categoryName: unknown; entries: unknown };
|
||||
|
||||
function isTemplateSection(x: unknown): x is TemplateSection {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return typeof o.categoryName === "string" && Array.isArray(o.entries);
|
||||
}
|
||||
|
||||
function entryTitles(entries: unknown): string[] {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out: string[] = [];
|
||||
for (const raw of entries) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const title = (raw as TemplateEntry).title;
|
||||
if (typeof title !== "string") continue;
|
||||
const trimmed = title.trim();
|
||||
if (trimmed.length > 0) out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalise a Figma template category header ("Decision-making") for matching. */
|
||||
function normaliseCategoryKey(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
}
|
||||
|
||||
/** Preset core-value labels with the chip id (1-based preset index as string) the select screen expects. */
|
||||
type CorePresetRow = { id: string; label: string };
|
||||
const CORE_VALUE_PRESETS: readonly CorePresetRow[] = (() => {
|
||||
const raw = (coreValuesMessages as { values: unknown }).values;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((v, i) => ({
|
||||
id: String(i + 1),
|
||||
label: typeof v === "string" ? v : (v as { label: string }).label,
|
||||
}));
|
||||
})();
|
||||
|
||||
function buildCoreValuePrefill(
|
||||
titles: readonly string[],
|
||||
): Pick<CreateFlowState, "selectedCoreValueIds" | "coreValuesChipsSnapshot"> {
|
||||
const wantedByLower = new Map<string, string>();
|
||||
for (const t of titles) wantedByLower.set(t.toLowerCase(), t);
|
||||
|
||||
const selected: string[] = [];
|
||||
const snapshot: CommunityStructureChipSnapshotRow[] = [];
|
||||
|
||||
for (const preset of CORE_VALUE_PRESETS) {
|
||||
const isSelected = wantedByLower.delete(preset.label.toLowerCase());
|
||||
snapshot.push({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
state: isSelected ? "selected" : "unselected",
|
||||
});
|
||||
if (isSelected) selected.push(preset.id);
|
||||
}
|
||||
|
||||
// Any template labels not matching a preset ride along as custom chip rows
|
||||
// so templates authored with bespoke values still pre-select on the screen.
|
||||
for (const original of wantedByLower.values()) {
|
||||
const id = `template-cv-${methodSlugFromTitle(original) || snapshot.length}`;
|
||||
snapshot.push({ id, label: original, state: "selected" });
|
||||
selected.push(id);
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCoreValueIds: selected,
|
||||
coreValuesChipsSnapshot: snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a curated template `body` (DB shape — `sections[]` with `categoryName`
|
||||
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
|
||||
* screens read for pre-selection. Used by the "Customize" handler on
|
||||
* `/create/review-template/[slug]` so clicking Customize drops the user into
|
||||
* the custom-rule flow with the template's chips already highlighted.
|
||||
*
|
||||
* Produces:
|
||||
* - `selectedCoreValueIds` + `coreValuesChipsSnapshot` — preset match by
|
||||
* label; non-matching titles become custom chip rows so bespoke template
|
||||
* values still appear selected.
|
||||
* - `selectedCommunicationMethodIds`, `selectedMembershipMethodIds`,
|
||||
* `selectedDecisionApproachIds`, `selectedConflictManagementIds` — chip
|
||||
* ids derived via {@link methodSlugFromTitle}, matching the `methods[].id`
|
||||
* produced by the one-time messages ingest.
|
||||
*
|
||||
* Returns an empty object for malformed bodies (no sections array).
|
||||
*/
|
||||
export function buildTemplateCustomizePrefill(
|
||||
body: unknown,
|
||||
): Partial<CreateFlowState> {
|
||||
if (!body || typeof body !== "object") return {};
|
||||
const sections = (body as { sections?: unknown }).sections;
|
||||
if (!Array.isArray(sections)) return {};
|
||||
|
||||
const prefill: Partial<CreateFlowState> = {};
|
||||
|
||||
for (const raw of sections) {
|
||||
if (!isTemplateSection(raw)) continue;
|
||||
const key = normaliseCategoryKey(raw.categoryName as string);
|
||||
const titles = entryTitles(raw.entries);
|
||||
if (titles.length === 0) continue;
|
||||
|
||||
if (key === "values" || key === "corevalues") {
|
||||
Object.assign(prefill, buildCoreValuePrefill(titles));
|
||||
continue;
|
||||
}
|
||||
|
||||
const slugs = titles.map(methodSlugFromTitle).filter((s) => s.length > 0);
|
||||
if (slugs.length === 0) continue;
|
||||
|
||||
switch (key) {
|
||||
case "communication":
|
||||
case "communications":
|
||||
prefill.selectedCommunicationMethodIds = slugs;
|
||||
break;
|
||||
case "membership":
|
||||
case "memberships":
|
||||
prefill.selectedMembershipMethodIds = slugs;
|
||||
break;
|
||||
case "decisionmaking":
|
||||
case "decisionapproaches":
|
||||
case "decisions":
|
||||
prefill.selectedDecisionApproachIds = slugs;
|
||||
break;
|
||||
case "conflictmanagement":
|
||||
case "conflict":
|
||||
case "conflictresolution":
|
||||
prefill.selectedConflictManagementIds = slugs;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return prefill;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import communicationMessages from "../../messages/en/create/customRule/communication.json";
|
||||
import conflictManagementMessages from "../../messages/en/create/customRule/conflictManagement.json";
|
||||
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
|
||||
import membershipMessages from "../../messages/en/create/customRule/membership.json";
|
||||
import {
|
||||
buildCoreValuesForDocument,
|
||||
parseSectionsFromCreateFlowState,
|
||||
} from "./buildPublishPayload";
|
||||
|
||||
/**
|
||||
* Chip row shape shared with `messages/en/create/reviewAndComplete/finalReview.json`
|
||||
* so the final-review screen can keep its existing category → chip label rendering
|
||||
* contract regardless of whether chips came from state or from fallback content.
|
||||
*/
|
||||
export type FinalReviewCategoryRow = { name: string; chips: string[] };
|
||||
|
||||
/** Category labels supplied by the caller (pulled from localized messages). */
|
||||
export type FinalReviewCategoryNames = {
|
||||
values: string;
|
||||
communication: string;
|
||||
membership: string;
|
||||
decisions: string;
|
||||
conflict: string;
|
||||
};
|
||||
|
||||
type MethodPreset = { id: string; label: string };
|
||||
|
||||
function readMethodsArray(source: unknown): MethodPreset[] {
|
||||
if (!source || typeof source !== "object") return [];
|
||||
const methods = (source as { methods?: unknown }).methods;
|
||||
if (!Array.isArray(methods)) return [];
|
||||
const out: MethodPreset[] = [];
|
||||
for (const raw of methods) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (typeof o.id === "string" && typeof o.label === "string") {
|
||||
out.push({ id: o.id, label: o.label });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function labelsFromIds(
|
||||
ids: readonly string[] | undefined,
|
||||
methods: readonly MethodPreset[],
|
||||
): string[] {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const byId = new Map(methods.map((m) => [m.id, m.label] as const));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of ids) {
|
||||
const label = byId.get(id);
|
||||
if (typeof label !== "string" || label.length === 0) continue;
|
||||
if (seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
out.push(label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the final-review RuleCard category rows from the current
|
||||
* {@link CreateFlowState}.
|
||||
*
|
||||
* Two-mode contract, mirroring the two template entry points:
|
||||
* 1. **Use without changes** — `state.sections` carries the applied template
|
||||
* body; we render it verbatim (`categoryName` + entry `title`s). Core
|
||||
* values still come from `buildCoreValuesForDocument` when they were
|
||||
* captured separately.
|
||||
* 2. **Customize / plain custom-rule flow** — each Create Custom screen writes
|
||||
* its selection ids into a dedicated state field. We resolve those ids
|
||||
* 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`.
|
||||
*/
|
||||
export function buildFinalReviewCategoriesFromState(
|
||||
state: CreateFlowState,
|
||||
names: FinalReviewCategoryNames,
|
||||
): FinalReviewCategoryRow[] {
|
||||
const sections = parseSectionsFromCreateFlowState(state);
|
||||
const coreValueLabels = buildCoreValuesForDocument(state).map((r) => r.label);
|
||||
|
||||
// Use-without-changes / pre-rendered template body: the sections array is
|
||||
// the source of truth. Collapse each section's entries to its titles; the
|
||||
// RuleCard category UI shows only labels, not per-entry body copy.
|
||||
if (sections.length > 0) {
|
||||
const rows: FinalReviewCategoryRow[] = [];
|
||||
|
||||
// If core values were also captured (e.g., the template surfaced both),
|
||||
// keep them up top for visual parity with the custom-rule flow. Otherwise
|
||||
// any `Values` section already inside `sections` covers the same ground.
|
||||
if (coreValueLabels.length > 0) {
|
||||
const hasValuesSection = sections.some(
|
||||
(s) => s.categoryName.toLowerCase() === names.values.toLowerCase(),
|
||||
);
|
||||
if (!hasValuesSection) {
|
||||
rows.push({ name: names.values, chips: coreValueLabels });
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of sections) {
|
||||
const chips = s.entries
|
||||
.map((e) => e.title.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
if (chips.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, chips });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const communicationMethods = readMethodsArray(communicationMessages);
|
||||
const membershipMethods = readMethodsArray(membershipMessages);
|
||||
const decisionApproachMethods = readMethodsArray(decisionApproachesMessages);
|
||||
const conflictManagementMethods = readMethodsArray(conflictManagementMessages);
|
||||
|
||||
const rows: FinalReviewCategoryRow[] = [
|
||||
{ name: names.values, chips: coreValueLabels },
|
||||
{
|
||||
name: names.communication,
|
||||
chips: labelsFromIds(
|
||||
state.selectedCommunicationMethodIds,
|
||||
communicationMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.membership,
|
||||
chips: labelsFromIds(
|
||||
state.selectedMembershipMethodIds,
|
||||
membershipMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.decisions,
|
||||
chips: labelsFromIds(
|
||||
state.selectedDecisionApproachIds,
|
||||
decisionApproachMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.conflict,
|
||||
chips: labelsFromIds(
|
||||
state.selectedConflictManagementIds,
|
||||
conflictManagementMethods,
|
||||
),
|
||||
},
|
||||
];
|
||||
return rows.filter((r) => r.chips.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import messages from "../../messages/en/index";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
type RuleTemplateDto,
|
||||
} from "./fetchTemplates";
|
||||
|
||||
export type LoadTemplateReviewResult =
|
||||
| { ok: true; template: RuleTemplateDto }
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Shared prelude for the two template-review actions (Customize and
|
||||
* "Use without changes") in `CreateFlowLayoutClient`. Wraps the slug →
|
||||
* `RuleTemplateDto` fetch and normalizes its three possible failures
|
||||
* (network / server / not-found) into a single localized error message
|
||||
* suitable for the template-review banner.
|
||||
*
|
||||
* Keeping the localized copy here (rather than in the fetch layer) means
|
||||
* callers only forward `result.message` to `setTemplateReviewApplyError`,
|
||||
* and both handlers resolve identical error text from a single source.
|
||||
*
|
||||
* Malformed template bodies (`body` not an object, missing `sections`,
|
||||
* etc.) remain the caller's responsibility because the expected shape
|
||||
* differs between Customize (prefill lookup) and Use-without-changes
|
||||
* (full section extraction). Those checks stay in the handlers that need
|
||||
* them so errors surface at the step where the shape matters.
|
||||
*/
|
||||
export async function loadTemplateReviewBySlug(
|
||||
slug: string,
|
||||
): Promise<LoadTemplateReviewResult> {
|
||||
const errors = messages.create.templateReview.errors;
|
||||
const result = await fetchTemplateBySlug(slug);
|
||||
if (result === null) {
|
||||
return { ok: false, message: errors.notFound };
|
||||
}
|
||||
if ("error" in result) {
|
||||
const trimmed = typeof result.error === "string" ? result.error.trim() : "";
|
||||
return {
|
||||
ok: false,
|
||||
message: trimmed.length > 0 ? trimmed : errors.applyFailed,
|
||||
};
|
||||
}
|
||||
return { ok: true, template: result };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Client-safe slugifier that mirrors the one-time ingest that produced
|
||||
* `data/create/customRule/<section>.json` `methods[].id`. Lives in
|
||||
* `lib/create/` (not `lib/server/`) so client code — specifically the
|
||||
* template "Customize" prefill — can map template entry titles to the chip
|
||||
* ids the customize screens read out of `CreateFlowState`.
|
||||
*
|
||||
* Rules: NFKD-normalize, strip diacritics, drop apostrophes/brackets,
|
||||
* collapse non-alphanumerics to single hyphens, trim leading/trailing
|
||||
* hyphens. Server-side `lib/server/templateMethods.ts` re-exports this.
|
||||
*/
|
||||
export function methodSlugFromTitle(title: string): string {
|
||||
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
||||
const stripped = folded
|
||||
.toLowerCase()
|
||||
.replace(/['’`()\[\]]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return stripped;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { methodSlugFromTitle } from "../create/methodSlugFromTitle";
|
||||
import type { SectionId } from "./validation/methodFacetsSchemas";
|
||||
|
||||
/**
|
||||
@@ -21,18 +22,7 @@ const CATEGORY_NAME_TO_SECTION: Record<string, SectionId> = {
|
||||
"Conflict management": "conflictManagement",
|
||||
};
|
||||
|
||||
export function methodSlugFromTitle(title: string): string {
|
||||
// Match the slugify rules of the one-time messages ingest: NFKD-normalize,
|
||||
// strip diacritics, drop apostrophes/brackets, collapse non-alphanumerics
|
||||
// to single hyphens, trim leading/trailing hyphens.
|
||||
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
||||
const stripped = folded
|
||||
.toLowerCase()
|
||||
.replace(/['’`()\[\]]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return stripped;
|
||||
}
|
||||
export { methodSlugFromTitle };
|
||||
|
||||
type RuleTemplateBodySection = {
|
||||
categoryName?: unknown;
|
||||
|
||||
@@ -67,6 +67,13 @@ export const createFlowStateSchema = z
|
||||
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
|
||||
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
|
||||
pendingTemplateAction: z
|
||||
.object({
|
||||
slug: z.string().max(200),
|
||||
mode: z.enum(["customize", "useWithoutChanges"]),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "Mutual Aid Mondays",
|
||||
"description": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.",
|
||||
"logoAlt": "Mutual Aid Mondays"
|
||||
"title": "Mutual Aid Mondays"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"useWithoutChanges": "Use without changes",
|
||||
"customize": "Customize",
|
||||
"customizeAriaHint": "Customize flow coming soon; for now this continues to the create flow entry."
|
||||
"customize": "Customize"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "We could not load this template. Try again or pick another template from the home page.",
|
||||
|
||||
@@ -100,3 +100,46 @@ describe("FinalReviewScreen", () => {
|
||||
expect(screen.getByText("Open Admission")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds a Customize-from-template style state (method ids + core-value
|
||||
* snapshot) and asserts the final-review RuleCard renders the resolved
|
||||
* labels — the fix for "preselected chips don't register on final review".
|
||||
*/
|
||||
function FinalReviewWithCustomizeSelections() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
title: "Oak Park Commons",
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
{ id: "2", label: "Accountability", state: "unselected" },
|
||||
],
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
selectedMembershipMethodIds: ["open-access"],
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
selectedConflictManagementIds: ["peer-mediation"],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
|
||||
describe("FinalReviewScreen — prefilled selections", () => {
|
||||
it("renders chips resolved from selection ids, not demo fallbacks", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Accessibility")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Signal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Open Access")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lazy Consensus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Peer Mediation")).toBeInTheDocument();
|
||||
|
||||
// Demo chips from `finalReview.json` must not leak through once the
|
||||
// user has real selections: "Open Admission" is shipped as fallback,
|
||||
// while the customize flow resolves to "Open Access".
|
||||
expect(screen.queryByText("Open Admission")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import { beforeEach, describe, it, expect } from "vitest";
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen";
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
|
||||
describe("CommunityReviewScreen", () => {
|
||||
beforeEach(() => {
|
||||
testRouter.replace.mockReset();
|
||||
testRouter.push.mockReset();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
@@ -27,18 +39,18 @@ describe("CommunityReviewScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard with title", () => {
|
||||
it("renders RuleCard with title fallback when no community name is set", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard with description", () => {
|
||||
it("omits the RuleCard description when the user has not entered community context", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i,
|
||||
screen.queryByText(
|
||||
/Mutual Aid Monday is a grassroots community in Denver/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RuleCard as a button (card is interactive)", () => {
|
||||
@@ -50,3 +62,60 @@ describe("CommunityReviewScreen", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds `pendingTemplateAction` into CreateFlowContext before the screen
|
||||
* under test mounts, so we can assert its mount-time redirect behavior.
|
||||
* Mirrors the flow `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges`
|
||||
* create when the user picks a template before completing community stage.
|
||||
*/
|
||||
function ReviewWithPendingAction({
|
||||
mode,
|
||||
}: {
|
||||
mode: "customize" | "useWithoutChanges";
|
||||
}) {
|
||||
const { state, updateState } = useCreateFlow();
|
||||
const seededRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
updateState({
|
||||
title: "Neighborhood",
|
||||
pendingTemplateAction: { slug: "mutual-aid-mondays", mode },
|
||||
});
|
||||
}, [mode, updateState]);
|
||||
// Block the real screen from mounting until the seed landed — otherwise
|
||||
// its own `useEffect` reads an empty state on the first pass and bails.
|
||||
if (!state.pendingTemplateAction) return null;
|
||||
return <CommunityReviewScreen />;
|
||||
}
|
||||
|
||||
describe("CommunityReviewScreen — pendingTemplateAction redirect", () => {
|
||||
beforeEach(() => {
|
||||
testRouter.replace.mockReset();
|
||||
testRouter.push.mockReset();
|
||||
});
|
||||
|
||||
it("redirects to /create/core-values when mode === 'customize'", async () => {
|
||||
render(<ReviewWithPendingAction mode="customize" />);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.replace).toHaveBeenCalledWith("/create/core-values");
|
||||
});
|
||||
expect(testRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to /create/confirm-stakeholders when mode === 'useWithoutChanges'", async () => {
|
||||
render(<ReviewWithPendingAction mode="useWithoutChanges" />);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.replace).toHaveBeenCalledWith(
|
||||
"/create/confirm-stakeholders",
|
||||
);
|
||||
});
|
||||
expect(testRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not redirect when no pendingTemplateAction is set", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(testRouter.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import TopNav from "../../app/components/navigation/TopNav";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
||||
import { CORE_VALUE_DETAILS_STORAGE_KEY } from "../../app/(app)/create/utils/coreValueDetailsLocalStorage";
|
||||
import { componentTestSuite } from "../utils/componentTestSuite";
|
||||
|
||||
// Mock next/navigation (TopNav uses useRouter for Create Rule button and usePathname for nav state)
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
push: pushMock,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
@@ -50,3 +57,45 @@ componentTestSuite<TopNavProps>({
|
||||
errorState: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('TopNav "Create rule" button', () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against localStorage stickiness on the marketing homepage: hitting
|
||||
* the top-nav "Create rule" from anywhere outside `/create` must wipe the
|
||||
* in-flight anonymous draft so the wizard always starts fresh. See
|
||||
* handleCreateRuleClick in TopNav.container.tsx for the contract.
|
||||
*/
|
||||
it("clears anonymous draft + core-value-details localStorage before routing to /create", async () => {
|
||||
window.localStorage.setItem(
|
||||
CREATE_FLOW_ANONYMOUS_KEY,
|
||||
JSON.stringify({ title: "Stale community" }),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||
JSON.stringify({ "1": { meaning: "m", signals: "s" } }),
|
||||
);
|
||||
|
||||
renderWithProviders(<TopNav folderTop={false} />);
|
||||
|
||||
// TopNav renders the Create Rule button at three breakpoints (xs/sm/md);
|
||||
// any of them clicking the same handler is the point.
|
||||
const [btn] = screen.getAllByRole("button", {
|
||||
name: /create a new rule/i,
|
||||
});
|
||||
await userEvent.click(btn);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(pushMock).toHaveBeenCalledWith("/create");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import {
|
||||
CreateFlowProvider,
|
||||
useCreateFlow,
|
||||
} from "../../app/(app)/create/context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Harness: mounts a consumer that renders the state we want to assert on and
|
||||
* exposes imperative handles (updateState, resetCustomRuleSelections) via
|
||||
* window globals. Keeps the test readable vs. threading refs everywhere.
|
||||
*/
|
||||
function Harness() {
|
||||
const { state, updateState, resetCustomRuleSelections } = useCreateFlow();
|
||||
(window as unknown as { __updateState: typeof updateState }).__updateState =
|
||||
updateState;
|
||||
(
|
||||
window as unknown as { __resetCustomRule: typeof resetCustomRuleSelections }
|
||||
).__resetCustomRule = resetCustomRuleSelections;
|
||||
return (
|
||||
<>
|
||||
<div data-testid="title">{state.title ?? ""}</div>
|
||||
<div data-testid="core">
|
||||
{(state.selectedCoreValueIds ?? []).join(",")}
|
||||
</div>
|
||||
<div data-testid="comm">
|
||||
{(state.selectedCommunicationMethodIds ?? []).join(",")}
|
||||
</div>
|
||||
<div data-testid="details">
|
||||
{Object.keys(state.coreValueDetailsByChipId ?? {}).join(",")}
|
||||
</div>
|
||||
<div data-testid="snapshot">
|
||||
{(state.coreValuesChipsSnapshot ?? []).map((r) => r.id).join(",")}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getUpdateState() {
|
||||
return (window as unknown as { __updateState: (u: unknown) => void })
|
||||
.__updateState;
|
||||
}
|
||||
|
||||
function getResetCustomRule() {
|
||||
return (window as unknown as { __resetCustomRule: () => void })
|
||||
.__resetCustomRule;
|
||||
}
|
||||
|
||||
describe("CreateFlowContext — resetCustomRuleSelections", () => {
|
||||
it("clears all custom-rule stage selections while keeping community stage", () => {
|
||||
render(
|
||||
<CreateFlowProvider>
|
||||
<Harness />
|
||||
</CreateFlowProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
getUpdateState()({
|
||||
title: "Mutual Aid Mondays",
|
||||
communityContext: "Neighborhood",
|
||||
selectedCoreValueIds: ["1", "3"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Trust", state: "selected" },
|
||||
],
|
||||
coreValueDetailsByChipId: {
|
||||
"1": { meaning: "m", signals: "s" },
|
||||
},
|
||||
selectedCommunicationMethodIds: ["consensus-decision-making"],
|
||||
selectedMembershipMethodIds: ["open"],
|
||||
selectedDecisionApproachIds: ["consensus-decision-making"],
|
||||
selectedConflictManagementIds: ["mediation"],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("title").textContent).toBe("Mutual Aid Mondays");
|
||||
expect(screen.getByTestId("core").textContent).toBe("1,3");
|
||||
expect(screen.getByTestId("comm").textContent).toBe(
|
||||
"consensus-decision-making",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
getResetCustomRule()();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("title").textContent).toBe("Mutual Aid Mondays");
|
||||
expect(screen.getByTestId("core").textContent).toBe("");
|
||||
expect(screen.getByTestId("comm").textContent).toBe("");
|
||||
expect(screen.getByTestId("details").textContent).toBe("");
|
||||
expect(screen.getByTestId("snapshot").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("is a no-op when no custom-rule selections were set", () => {
|
||||
render(
|
||||
<CreateFlowProvider>
|
||||
<Harness />
|
||||
</CreateFlowProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
getUpdateState()({ title: "Just a Community" });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
getResetCustomRule()();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("title").textContent).toBe("Just a Community");
|
||||
expect(screen.getByTestId("core").textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -4,17 +4,35 @@ import {
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, test, expect, afterEach, beforeEach } from "vitest";
|
||||
import { describe, test, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import TemplatesPageClient from "../../app/(marketing)/templates/TemplatesPageClient";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
|
||||
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
||||
import { CORE_VALUE_DETAILS_STORAGE_KEY } from "../../app/(app)/create/utils/coreValueDetailsLocalStorage";
|
||||
|
||||
/** Seed localStorage as if a stale anonymous draft were already in place. */
|
||||
function seedStaleDraft() {
|
||||
window.localStorage.setItem(
|
||||
CREATE_FLOW_ANONYMOUS_KEY,
|
||||
JSON.stringify({ title: "Stale Community" }),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testRouter.push.mockClear();
|
||||
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams());
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Templates page (/templates)", () => {
|
||||
@@ -54,4 +72,51 @@ describe("Templates page (/templates)", () => {
|
||||
"/create/review-template/solidarity-network",
|
||||
);
|
||||
});
|
||||
|
||||
test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => {
|
||||
seedStaleDraft();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
|
||||
test("in-flow entry (?fromFlow=1): preserves the anonymous draft", async () => {
|
||||
vi.mocked(useSearchParams).mockReturnValue(
|
||||
new URLSearchParams("fromFlow=1"),
|
||||
);
|
||||
seedStaleDraft();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe(
|
||||
JSON.stringify({ title: "Stale Community" }),
|
||||
);
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBe(
|
||||
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
||||
);
|
||||
// No `?fromFlow=1` on the outbound review-template URL — the marker
|
||||
// only disambiguates /templates' own click behavior.
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
getGovernanceTemplatesForHome,
|
||||
} from "../../lib/templates/governanceTemplateCatalog";
|
||||
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
||||
import { CORE_VALUE_DETAILS_STORAGE_KEY } from "../../app/(app)/create/utils/coreValueDetailsLocalStorage";
|
||||
|
||||
const homeFeatured = getGovernanceTemplatesForHome();
|
||||
|
||||
@@ -212,6 +214,31 @@ describe("RuleStack Component", () => {
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("template click from home wipes any stale anonymous draft", async () => {
|
||||
window.localStorage.setItem(
|
||||
CREATE_FLOW_ANONYMOUS_KEY,
|
||||
JSON.stringify({ title: "Stale Community" }),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test("renders with proper semantic structure", async () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTemplateCustomizePrefill } from "../../lib/create/applyTemplatePrefill";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
|
||||
function coreValuePresetId(label: string): string {
|
||||
const values = coreValuesMessages.values as Array<
|
||||
string | { label: string }
|
||||
>;
|
||||
const idx = values.findIndex((v) => {
|
||||
const l = typeof v === "string" ? v : v.label;
|
||||
return l.toLowerCase() === label.toLowerCase();
|
||||
});
|
||||
return String(idx + 1);
|
||||
}
|
||||
|
||||
describe("buildTemplateCustomizePrefill", () => {
|
||||
it("returns an empty object for malformed bodies", () => {
|
||||
expect(buildTemplateCustomizePrefill(null)).toEqual({});
|
||||
expect(buildTemplateCustomizePrefill({})).toEqual({});
|
||||
expect(buildTemplateCustomizePrefill({ sections: "nope" })).toEqual({});
|
||||
});
|
||||
|
||||
it("maps communication / membership / decisions / conflict titles to method-id slugs", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{ title: "In-Person Meetings", body: "x" },
|
||||
{ title: "Loomio", body: "y" },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [{ title: "Peer Sponsorship", body: "m" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [{ title: "Consensus Decision-Making", body: "d" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [{ title: "Restorative Justice", body: "c" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(buildTemplateCustomizePrefill(body)).toEqual({
|
||||
selectedCommunicationMethodIds: ["in-person-meetings", "loomio"],
|
||||
selectedMembershipMethodIds: ["peer-sponsorship"],
|
||||
selectedDecisionApproachIds: ["consensus-decision-making"],
|
||||
selectedConflictManagementIds: ["restorative-justice"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches template Values against the preset list and marks them selected", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Consensus", body: "" },
|
||||
{ title: "Community Care", body: "" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const prefill = buildTemplateCustomizePrefill(body);
|
||||
const selected = prefill.selectedCoreValueIds ?? [];
|
||||
expect(selected).toContain(coreValuePresetId("Consensus"));
|
||||
expect(selected).toContain(coreValuePresetId("Community Care"));
|
||||
|
||||
const snapshot = prefill.coreValuesChipsSnapshot ?? [];
|
||||
const selectedRows = snapshot.filter((r) => r.state === "selected");
|
||||
expect(selectedRows.map((r) => r.label).sort()).toEqual([
|
||||
"Community Care",
|
||||
"Consensus",
|
||||
]);
|
||||
// Unmatched presets should still appear, as unselected, so the screen
|
||||
// renders the full chip list (the select screen reads the snapshot as-is).
|
||||
expect(snapshot.length).toBeGreaterThan(selectedRows.length);
|
||||
});
|
||||
|
||||
it("preserves bespoke template values as custom chip rows", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Very Bespoke Thing", body: "" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const prefill = buildTemplateCustomizePrefill(body);
|
||||
const custom = (prefill.coreValuesChipsSnapshot ?? []).find(
|
||||
(r) => r.label === "Very Bespoke Thing",
|
||||
);
|
||||
expect(custom).toBeDefined();
|
||||
expect(custom?.state).toBe("selected");
|
||||
expect(prefill.selectedCoreValueIds).toContain(custom?.id);
|
||||
});
|
||||
|
||||
it("ignores unknown category names", () => {
|
||||
const prefill = buildTemplateCustomizePrefill({
|
||||
sections: [
|
||||
{ categoryName: "Mystery", entries: [{ title: "What", body: "" }] },
|
||||
],
|
||||
});
|
||||
expect(prefill).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFinalReviewCategoriesFromState } from "../../lib/create/buildFinalReviewCategories";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
const NAMES = {
|
||||
values: "Values",
|
||||
communication: "Communication",
|
||||
membership: "Membership",
|
||||
decisions: "Decision-making",
|
||||
conflict: "Conflict management",
|
||||
};
|
||||
|
||||
describe("buildFinalReviewCategoriesFromState", () => {
|
||||
it("returns [] when state has no selections and no sections", () => {
|
||||
expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves method ids to labels from message presets", () => {
|
||||
// IDs here match `messages/en/create/customRule/*.json` `methods[].id`.
|
||||
// Same shape `buildTemplateCustomizePrefill` emits via methodSlugFromTitle.
|
||||
const state: CreateFlowState = {
|
||||
selectedCommunicationMethodIds: ["signal", "in-person-meetings"],
|
||||
selectedMembershipMethodIds: ["open-access"],
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
selectedConflictManagementIds: ["peer-mediation"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
const byName = new Map(rows.map((r) => [r.name, r.chips]));
|
||||
expect(byName.get("Communication")).toEqual([
|
||||
"Signal",
|
||||
"In-Person Meetings",
|
||||
]);
|
||||
expect(byName.get("Membership")).toEqual(["Open Access"]);
|
||||
expect(byName.get("Decision-making")).toEqual(["Lazy Consensus"]);
|
||||
expect(byName.get("Conflict management")).toEqual(["Peer Mediation"]);
|
||||
expect(byName.has("Values")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => {
|
||||
const state: CreateFlowState = {
|
||||
selectedCoreValueIds: ["1", "custom-one"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
{ id: "2", label: "Accountability", state: "unselected" },
|
||||
{ id: "custom-one", label: "Resilience", state: "selected" },
|
||||
],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Values", chips: ["Accessibility", "Resilience"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops unknown ids silently instead of inserting empty labels", () => {
|
||||
const state: CreateFlowState = {
|
||||
selectedCommunicationMethodIds: ["signal", "bogus-id"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
|
||||
});
|
||||
|
||||
it("dedupes repeated labels from duplicate ids", () => {
|
||||
const state: CreateFlowState = {
|
||||
selectedCommunicationMethodIds: ["signal", "signal"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
|
||||
});
|
||||
|
||||
it("prefers state.sections when populated (use-without-changes path)", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Consciousness", body: "…" },
|
||||
{ title: "Ecology", body: "…" },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
// Selection ids must be ignored when sections is present — the
|
||||
// "Use without changes" handler resets them for exactly that reason,
|
||||
// but we double-check the helper honors the sections branch first.
|
||||
selectedCommunicationMethodIds: ["in-person-meetings"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Values", chips: ["Consciousness", "Ecology"] },
|
||||
{ name: "Communication", chips: ["Signal"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Values", chips: ["Accessibility"] },
|
||||
{ name: "Communication", chips: ["Signal"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate Values when sections already includes one", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Consciousness", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Values", chips: ["Consciousness"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { hasCreateFlowUserInput } from "../../app/(app)/create/utils/hasCreateFlowUserInput";
|
||||
|
||||
describe("hasCreateFlowUserInput", () => {
|
||||
it("returns false for empty state", () => {
|
||||
expect(hasCreateFlowUserInput({})).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores currentStep alone", () => {
|
||||
expect(hasCreateFlowUserInput({ currentStep: "informational" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for non-empty title", () => {
|
||||
expect(hasCreateFlowUserInput({ title: "My rule" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for whitespace-only title", () => {
|
||||
expect(hasCreateFlowUserInput({ title: " " })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for non-empty sections array", () => {
|
||||
expect(hasCreateFlowUserInput({ sections: [{}] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty sections array", () => {
|
||||
expect(hasCreateFlowUserInput({ sections: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for extra step-specific keys with content", () => {
|
||||
expect(hasCreateFlowUserInput({ cards: ["a"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for extra keys with empty object", () => {
|
||||
expect(hasCreateFlowUserInput({ foo: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user