Template flow cleaned up

This commit is contained in:
adilallo
2026-04-20 16:45:15 -06:00
parent d3bb8cdd0f
commit c08cd62872
32 changed files with 1545 additions and 254 deletions
+165 -109
View File
@@ -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
View File
@@ -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
View File
@@ -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).
---
+2 -2
View File
@@ -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.
+27 -7
View File
@@ -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.
+145
View File
@@ -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;
}
+152
View File
@@ -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);
}
+44
View File
@@ -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 };
}
+20
View File
@@ -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;
}
+2 -12
View File
@@ -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(),
+1 -3
View File
@@ -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"
}
}
+1 -2
View File
@@ -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.",
+43
View File
@@ -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();
});
});
+76 -7
View File
@@ -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();
});
});
+52 -3
View File
@@ -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");
});
});
+111
View File
@@ -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("");
});
});
+66 -1
View File
@@ -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",
);
});
});
+27
View File
@@ -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();
+109
View File
@@ -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"] },
]);
});
});
-38
View File
@@ -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);
});
});