Compare commits

...
4 Commits
Author SHA1 Message Date
adilalloandCursor b6afdb6e32 Let Use without changes from a template collect identity instead of the full questionnaire.
Direct catalog picks walk name, description, and photo, then stakeholders; leftover drafts no longer skip that path. In-flow template picks keep community identity. Exit on a direct template preview leaves immediately because nothing has been collected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:00:17 -06:00
adilalloandCursor f91ac7a893 Call the last create-flow action Publish so it reads as putting a rule on the web, not locking it forever.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:27:57 -06:00
adilalloandCursor db34a1f0df Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.
Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:18:41 -06:00
adilalloandCursor 84ef943df4 Show a footer Remove on selected create-flow modules so removal is not kebab-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:45:56 -06:00
57 changed files with 1268 additions and 197 deletions
+79 -25
View File
@@ -23,8 +23,9 @@ import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep,
getStepIndex,
parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
@@ -64,6 +65,10 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
} from "../../../lib/create/pendingKeepRuleLogin";
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index";
import {
@@ -91,9 +96,6 @@ import {
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
@@ -111,9 +113,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users;
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
// the server (handled in `useCreateFlowExit`).
// they are. Refresh-survival is the same UX for guest and signed-in users.
// Save & Exit: guests open the save-progress login modal; signed-in users
// PUT the server draft (`useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved;
return (
@@ -154,9 +156,9 @@ function CreateFlowLayoutContent({
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
canGoBack,
templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
@@ -197,6 +199,8 @@ function CreateFlowLayoutContent({
} | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
@@ -267,17 +271,20 @@ function CreateFlowLayoutContent({
title: completedCopy.guestInvitesSkippedTitle,
description: completedCopy.guestInvitesSkippedDescription,
});
return;
}
} else {
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
}
},
});
const fromCreateWizard =
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE;
const {
isTemplateReviewRoute,
templateReviewSlug,
@@ -292,6 +299,8 @@ function CreateFlowLayoutContent({
updateState,
replaceState,
router,
fromCreateWizard,
markCreateFlowInteraction,
});
const runAuthenticatedExit = useCreateFlowExit({
@@ -305,14 +314,34 @@ function CreateFlowLayoutContent({
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
// Direct template preview: browsing a catalog card, nothing collected yet.
// In-flow (`?fromFlow=1`) still has community progress — keep save / confirm.
if (isDirectTemplateReviewEntry(pathname, searchParams)) {
clearState();
router.push(CREATE_ROUTES.root);
return;
}
if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule
// login (the document is live but unclaimed). Signed-in users just leave.
if (currentStep === "completed") {
if (sessionUser === null) {
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
onDismiss: () => {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
},
});
return;
}
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
@@ -322,7 +351,6 @@ function CreateFlowLayoutContent({
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
@@ -428,9 +456,32 @@ function CreateFlowLayoutContent({
useEffect(() => {
if (currentStep !== "completed") {
setCompletedFlowBanner(null);
setCompletedHasPublishedRule(false);
return;
}
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
}, [currentStep]);
useEffect(() => {
if (currentStep !== "completed") return;
if (!sessionResolved) return;
if (sessionUser !== null) {
clearPendingKeepRuleLogin();
return;
}
if (!hasPendingKeepRuleLogin()) return;
const timeoutId = window.setTimeout(() => {
if (!hasPendingKeepRuleLogin()) return;
clearPendingKeepRuleLogin();
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
});
}, 0);
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
@@ -486,7 +537,6 @@ function CreateFlowLayoutContent({
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep,
);
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
const mainContentClass = isCompletedStep
@@ -506,9 +556,10 @@ function CreateFlowLayoutContent({
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) &&
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit(
currentStep,
sessionUser,
);
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
@@ -556,7 +607,7 @@ function CreateFlowLayoutContent({
key: "publish",
status: "danger" as const,
title:
messages.create.reviewAndComplete.publish.finalizeBannerTitle,
messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null),
}
@@ -674,7 +725,10 @@ function CreateFlowLayoutContent({
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep && Boolean(sessionUser)}
hasEdit={
isCompletedStep &&
(Boolean(sessionUser) || completedHasPublishedRule)
}
hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
@@ -916,8 +970,8 @@ function CreateFlowLayoutContent({
{isFinalReviewLike
? isPublishing
? messages.create.reviewAndComplete.publish
.finalizeButtonPublishing
: footer.finalizeCommunityRule
.publishButtonPublishing
: footer.publishCommunityRule
: getDefaultFooterLabel(currentStep, footer)}
</Button>
) : null
@@ -939,7 +993,7 @@ function CreateFlowLayoutContent({
),
);
}
: previousStep
: canGoBack()
? goToPreviousStep
: undefined
}
@@ -102,6 +102,18 @@ export function SignedInDraftHydration({
if (urlStep === "completed") {
return;
}
/**
* Direct template preview is a new-rule entry, not “continue draft”.
* Applying a saved `currentStep` here is what yanked Use without changes
* past name / description / photo to stakeholders.
*/
if (pathname?.includes("/create/review-template/")) {
return;
}
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false;
setLoadingHydration(true);
@@ -4,7 +4,8 @@
* Final-review chip modal: **Core values** and **method** facets share the
* kebab → **Duplicate** (values only when under the cap) / **Remove** pattern
* from the create-card facet modals (`Create` +
* {@link buildCustomRuleModalKebabMenu}). Values and method chips also offer
* {@link buildCustomRuleModalKebabMenu}), plus a footer **Remove** when the
* chip is already in the selection. Values and method chips also offer
* **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the
* chip. Fields are editable on open; Save persists body edits without renaming.
*
@@ -1073,12 +1074,23 @@ export function FinalReviewChipEditModal({
);
}, [subtitle, target]);
const showFooterRemove =
target != null &&
(target.groupKey === "coreValues" || isChipInSelection);
return (
<>
<Create
isOpen={isOpen && !addCustomWizardOpen}
onClose={handleModalClose}
onBack={handleModalClose}
showBackButton={!showFooterRemove}
showRemoveButton={showFooterRemove}
onRemove={
target?.groupKey === "coreValues"
? handleRemoveCoreValueFromModal
: handleRemoveSelectedFromModal
}
backdropVariant="blurredYellow"
headerContent={headerContent}
showNextButton={true}
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types";
import {
@@ -15,7 +16,7 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: {
variant: "default" | "saveProgress";
variant: "default" | "saveProgress" | "keepRule";
nextPath: string;
backdropVariant: "blurredYellow";
}) => void;
@@ -46,7 +47,7 @@ export function useCreateFlowFinalize({
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
loginReturnPath: string;
/**
* `undefined` while `/api/auth/session` is in flight — finalize is a no-op
* `undefined` while `/api/auth/session` is in flight — publish is a no-op
* until it resolves. `null` is a guest (anonymous publish).
*/
sessionUser: SessionUser | null | undefined;
@@ -79,6 +80,21 @@ export function useCreateFlowFinalize({
? state.editingPublishedRuleId.trim()
: "";
if (editingId.length > 0 && sessionUser === null) {
setIsPublishing(false);
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: false });
router.push(createFlowStepPath("completed"));
return;
}
if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, {
title,
@@ -99,8 +115,11 @@ export function useCreateFlowFinalize({
}
if (updateResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
@@ -135,6 +154,7 @@ export function useCreateFlowFinalize({
document: ruleDocument,
});
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push(
@@ -147,8 +167,11 @@ export function useCreateFlowFinalize({
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
@@ -34,9 +34,12 @@ const blurActiveElement = (): void => {
*
* Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
* from `confirm-stakeholders` instead of `conflict-management`.
* {@link resolveCreateFlowBackTarget}: template **Use without changes** that
* still needs identity (direct from a template) walks name / description /
* photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* to the template instead of `conflict-management`.
*
* Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
@@ -83,17 +86,27 @@ export function useCreateFlowNavigation(
updateState,
]);
const nextStep = getNextStep(validStep, options);
const previousStep = getPreviousStep(validStep, options);
const identityOnly =
state.pendingTemplateAction?.mode === "useWithoutChanges";
const navigationOptions = useMemo(
(): CreateFlowNavigationOptions => ({
skipCommunitySave: options?.skipCommunitySave,
useWithoutChangesIdentityOnly: identityOnly,
}),
[options?.skipCommunitySave, identityOnly],
);
const nextStep = getNextStep(validStep, navigationOptions);
const previousStep = getPreviousStep(validStep, navigationOptions);
const backTarget = useMemo(
() =>
resolveCreateFlowBackTarget(
validStep,
options,
navigationOptions,
state.templateReviewBackSlug,
),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
[validStep, navigationOptions, state.templateReviewBackSlug],
);
const goToNextStep = useCallback(() => {
@@ -10,6 +10,7 @@ import type {
CreateFlowContextValue,
CreateFlowState,
} from "../types";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"];
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
* Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
* skip past `/create/review` to `/create/confirm-stakeholders` later).
* the Values row from `state.sections`. Direct template entry routes to
* `/create/community-name` with a pin so Next/Back collect only name →
* description → photo. In-flow (`?fromFlow=1`, community steps already done)
* routes to `/create/confirm-stakeholders`.
*/
handleUseWithoutChanges: () => Promise<void>;
};
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
* setTemplateReviewApplyError,
* handleCustomize,
* handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
* } = useTemplateReviewActions({
* pathname, state, updateState, replaceState, router,
* fromCreateWizard, markCreateFlowInteraction,
* });
*/
export function useTemplateReviewActions({
pathname,
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
updateState,
replaceState,
router,
fromCreateWizard = false,
markCreateFlowInteraction,
}: {
pathname: string | null | undefined;
state: CreateFlowState;
updateState: UpdateState;
replaceState: ReplaceStateFn;
router: AppRouterLike;
/**
* True when this template-review URL was opened from the create wizard
* (`?fromFlow=1`). Direct catalog / marketing entry is false — a leftover
* draft title must not count as “community steps already done”.
*/
fromCreateWizard?: boolean;
markCreateFlowInteraction: () => void;
}): UseTemplateReviewActionsResult {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
}),
});
router.push(
hasCommunityName ? "/create/core-values" : "/create/informational",
hasCommunityName
? createFlowStepPath("core-values")
: createFlowStepPath("informational"),
);
}, [
router,
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
state.title,
templateReviewSlug,
updateState,
markCreateFlowInteraction,
]);
const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
return;
}
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode).
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
})
: sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill);
return {
const seeded: CreateFlowState = {
...base,
...(hasValuesSeed
? {
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName
? { pendingTemplateAction: undefined }
: {
};
if (fromCreateWizard) {
return {
...seeded,
pendingTemplateAction: undefined,
};
}
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
}),
};
});
router.push(
hasCommunityName
? "/create/confirm-stakeholders"
: "/create/informational",
fromCreateWizard
? createFlowStepPath("confirm-stakeholders")
: createFlowStepPath("community-name"),
);
}, [replaceState, router, state.title, templateReviewSlug]);
}, [
fromCreateWizard,
markCreateFlowInteraction,
replaceState,
router,
templateReviewSlug,
]);
return {
isTemplateReviewRoute,
@@ -9,10 +9,10 @@
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*
* Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
* action is **Add Platform** for an unselected card and **Save** for a selected
* card. **Remove** is available from the kebab (same behavior as legacy
* footer remove via {@link removeMethodCardFromFacetSelection}).
* editable sections rendered by {@link CommunicationMethodEditFields}. The
* primary action is **Add Platform** for an unselected card and **Save** for a
* selected card. **Remove** is a danger footer button on selected cards (and
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -747,6 +747,8 @@ export function CommunicationMethodsScreen() {
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -748,6 +748,8 @@ export function ConflictManagementScreen() {
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -741,6 +741,8 @@ export function MembershipMethodsScreen() {
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -27,14 +27,11 @@ export function CommunityReviewScreen() {
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.
* If the user picked **Customize** from a template before finishing community
* stage, we pinned `pendingTemplateAction` so this screen can skip itself
* and `replace` to `core-values`. **Use without changes** does not skip
* identity: if they land here with that pin, send them to `community-name`
* and leave the pin so name → description → photo stay in the skip list.
*
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition.
@@ -44,20 +41,19 @@ export function CommunityReviewScreen() {
if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction;
if (!pending) return;
const target =
pending.mode === "customize"
? createFlowStepPath("core-values")
: createFlowStepPath("confirm-stakeholders");
if (pending.mode !== "customize") {
firedRedirectRef.current = true;
const pinMerge =
pending.mode === "customize"
? {
router.replace(createFlowStepPath("community-name"));
return;
}
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true;
const pinMerge = {
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state),
},
}
: {};
};
updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target);
}, [
@@ -97,8 +97,9 @@ export function FinalReviewScreen({
/**
* Two modals coexist on this screen:
*
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab
* Remove; values also offer Duplicate under the five-chip cap; method chips
* - {@link FinalReviewChipEditModal} — core values + method chips: footer
* **Remove** when the chip is already selected, plus kebab **Remove**;
* values also offer Duplicate under the five-chip cap; method chips
* offer Customize (prefilled custom-policy wizard). Fields are editable on
* open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* also writes `customMethodCardMetaById`.
@@ -811,6 +811,8 @@ export function DecisionApproachesScreen() {
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -751,6 +751,8 @@ export function CoreValuesSelectScreen() {
}
showBackButton={false}
showNextButton={showFooterPrimary}
showRemoveButton={modalSession === "editing"}
onRemove={handleRemoveFromKebab}
onNext={handleModalConfirm}
nextButtonText={
modalSession === "editing"
+13 -15
View File
@@ -194,11 +194,13 @@ export interface CreateFlowState {
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/**
* 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.
* completing the community stage. Customize: the community-review screen
* `router.replace`s past `/create/review` to `core-values`. Use without
* changes: navigation skips to name → description → optional photo, then
* `confirm-stakeholders` (the pin keeps that skip list active). Set only for
* **direct** template entry, not when community identity was already collected
* in-flow. Cleared when community-review consumes a customize pin, or when
* the flow is reset.
*/
pendingTemplateAction?: {
slug: string;
@@ -206,12 +208,9 @@ export interface CreateFlowState {
};
/**
* Set when the user chooses **Use without changes** on a template-review
* page. The custom-rule segment (`core-values` … `conflict-management`) is
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly
* point at `conflict-management`. Navigation uses this slug so Back from
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
* Cleared when the user picks **Customize** from template review (normal
* linear back applies) or when the flow state is cleared.
* page. Used so Back from `community-name` (identity-only path) returns to
* `/create/review-template/{slug}` instead of the intro. Cleared when the user
* picks **Customize** from template review or when the flow state is cleared.
*/
templateReviewBackSlug?: string;
/**
@@ -222,7 +221,7 @@ export interface CreateFlowState {
*/
templateReviewEntryFromCreateFlow?: boolean;
/**
* When set, **Finalize** and signed-in **Save & Exit** update this published
* When set, **Publish** and signed-in **Save & Exit** update this published
* rule (PATCH) instead of POSTing a new rule or only saving a draft.
*/
editingPublishedRuleId?: string;
@@ -279,9 +278,8 @@ export interface CreateFlowContextValue {
*
* 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.
* of in-progress local state. Save & Exit visibility is
* `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
@@ -10,8 +10,8 @@ type FooterMessages = typeof footerMessages;
* from this map fall back to `footer.next`.
*
* `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`finalizeButtonPublishing`
* vs `finalizeCommunityRule`).
* also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `publishCommunityRule`).
*/
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep,
+108 -16
View File
@@ -47,8 +47,44 @@ export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
export type CreateFlowNavigationOptions = {
skipCommunitySave?: boolean;
/**
* Template **Use without changes**: collect community name, description, and
* optional photo only. Skips intro, structure, size, save, community review,
* and the custom-rule authoring segment.
*/
useWithoutChangesIdentityOnly?: boolean;
};
/**
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` → `community-context` →
* `community-upload`, then `confirm-stakeholders`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
"community-structure",
"community-size",
"community-save",
"review",
"core-values",
"communication-methods",
"membership-methods",
"decision-approaches",
"conflict-management",
]);
function shouldSkipStep(
step: CreateFlowStep,
options?: CreateFlowNavigationOptions,
): boolean {
if (options?.skipCommunitySave && step === "community-save") return true;
return Boolean(
options?.useWithoutChangesIdentityOnly &&
USE_WITHOUT_CHANGES_IDENTITY_SKIP.has(step),
);
}
/**
* Returns the next step in the flow, or null if current is last/invalid
*/
@@ -57,13 +93,14 @@ export function getNextStep(
options?: CreateFlowNavigationOptions,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
if (options?.skipCommunitySave && next === "community-save") {
return getNextStep("community-save", options);
let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1) return null;
while (index < FLOW_STEP_ORDER.length - 1) {
index += 1;
const next = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(next, options)) return next;
}
return next;
return null;
}
/**
@@ -74,20 +111,27 @@ export function getPreviousStep(
options?: CreateFlowNavigationOptions,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null;
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
if (options?.skipCommunitySave && prev === "community-save") {
return getPreviousStep("community-save", options);
while (index > 0) {
index -= 1;
const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(prev, options)) return prev;
}
return prev;
return null;
}
/**
* Where the create-flow footer Back action should go. Usually the previous
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders`
* via template **Use without changes**, Back returns to template review instead
* of `conflict-management` (that segment was skipped).
* step in {@link FLOW_STEP_ORDER}.
*
* Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* `conflict-management`.
*/
export type CreateFlowBackTarget =
| { kind: "step"; step: CreateFlowStep }
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
typeof templateReviewBackSlug === "string"
? templateReviewBackSlug.trim()
: "";
if (currentStep === "confirm-stakeholders" && slug.length > 0) {
if (
currentStep === "community-name" &&
options?.useWithoutChangesIdentityOnly &&
slug.length > 0
) {
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
return { kind: "templateReview", slug };
}
const prev = getPreviousStep(currentStep, options);
@@ -117,6 +172,27 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step);
}
/** First wizard step that offers Save & Exit (first Create Community select). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
/**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
*
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest — completed stays Exit until the session resolves.
*/
export function shouldOfferCreateFlowSaveAndExit(
currentStep: CreateFlowStep | null | undefined,
sessionUser?: { id: string } | null,
): boolean {
if (currentStep == null) return false;
if (currentStep === "completed") return sessionUser === null;
if (currentStep === "edit-rule") return true;
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
}
/**
* Steps where below `lg` the main column scrolls with split layout
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
@@ -173,6 +249,22 @@ export function parseCreateFlowScreenFromPathname(
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/**
* Catalog / marketing template preview (`/create/review-template/[slug]`
* without `?fromFlow=1`). There is nothing to save yet; top-nav Exit should
* leave immediately. In-flow picks keep the usual save / leave-confirm path.
*/
export function isDirectTemplateReviewEntry(
pathname: string | null | undefined,
searchParams?: { get: (name: string) => string | null } | null,
): boolean {
if (!pathname?.includes("/create/review-template/")) return false;
return (
searchParams?.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
);
}
/**
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
@@ -181,7 +273,7 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */
/** `/create/completed?celebrate=1` — post-publish toast; set only after **initial** POST publish, not PATCH updates. */
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
@@ -102,7 +102,9 @@ function TemplatesGrid({
entries={entries}
onTemplateClick={(slug) => {
if (!fromFlow) {
prepareFreshCreateFlowEntrySync();
// Marketing /templates has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
}
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
@@ -21,10 +21,13 @@ const CreateContainer = memo<CreateProps>(
footerClassName,
showBackButton = true,
showNextButton = true,
showRemoveButton = false,
onBack,
onNext,
onRemove,
backButtonText = "Back",
nextButtonText = "Next",
removeButtonText,
nextButtonDisabled = false,
currentStep,
totalSteps,
@@ -56,10 +59,13 @@ const CreateContainer = memo<CreateProps>(
footerClassName={footerClassName}
showBackButton={showBackButton}
showNextButton={showNextButton}
showRemoveButton={showRemoveButton}
onBack={onBack}
onNext={onNext}
onRemove={onRemove}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
@@ -16,10 +16,13 @@ export interface CreateProps {
footerClassName?: string;
showBackButton?: boolean;
showNextButton?: boolean;
showRemoveButton?: boolean;
onBack?: () => void;
onNext?: () => void;
onRemove?: () => void;
backButtonText?: string;
nextButtonText?: string;
removeButtonText?: string;
nextButtonDisabled?: boolean;
currentStep?: number;
totalSteps?: number;
@@ -58,10 +61,13 @@ export interface CreateViewProps {
footerClassName?: string;
showBackButton: boolean;
showNextButton: boolean;
showRemoveButton: boolean;
onBack?: () => void;
onNext?: () => void;
onRemove?: () => void;
backButtonText: string;
nextButtonText: string;
removeButtonText?: string;
nextButtonDisabled: boolean;
currentStep?: number;
totalSteps?: number;
@@ -17,10 +17,13 @@ export function CreateView({
footerClassName,
showBackButton,
showNextButton,
showRemoveButton,
onBack,
onNext,
onRemove,
backButtonText,
nextButtonText,
removeButtonText,
nextButtonDisabled,
currentStep,
totalSteps,
@@ -76,10 +79,13 @@ export function CreateView({
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
showRemoveButton={showRemoveButton}
onBack={onBack}
onNext={onNext}
onRemove={onRemove}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
+4 -1
View File
@@ -31,7 +31,10 @@ export function LoginView({
<div
ref={backdropRef}
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
onClick={onClose}
onPointerDown={(event) => {
if (event.target !== event.currentTarget) return;
onClose();
}}
role="presentation"
>
<div
+54 -20
View File
@@ -44,17 +44,51 @@ function MailIconInline() {
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export type LoginFormVariant = "default" | "saveProgress";
export type LoginFormVariant = "default" | "saveProgress" | "keepRule";
function loginFormHeading(
variant: LoginFormVariant,
sent: boolean,
t: (_key: string) => string,
): { title: string; description: string } {
if (sent) {
return { title: t("successTitle"), description: t("successBody") };
}
if (variant === "saveProgress") {
return {
title: t("saveProgressTitle"),
description: t("saveProgressSubtitle"),
};
}
if (variant === "keepRule") {
return {
title: t("keepRuleTitle"),
description: t("keepRuleSubtitle"),
};
}
return { title: t("title"), description: t("subtitle") };
}
function shouldAttachCreateFlowDraft(
variant: LoginFormVariant,
nextPath: string,
): boolean {
if (variant === "keepRule") return false;
return variant === "saveProgress" || nextPath.includes("syncDraft=1");
}
export type LoginFormProps = {
variant?: LoginFormVariant;
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
magicLinkNextPath?: string;
/** `keepRule`: Continue without saving. */
onDismiss?: () => void;
};
export default function LoginForm({
variant = "default",
magicLinkNextPath,
onDismiss,
}: LoginFormProps) {
const t = useTranslation("pages.login");
const tFooter = useTranslation("footer");
@@ -72,7 +106,7 @@ export default function LoginForm({
const nextParam = searchParams.get("next");
const errorParam = searchParams.get("error");
const isSaveProgress = variant === "saveProgress";
const heading = loginFormHeading(variant, sent, t);
/** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */
const stripErrorQuery = useCallback(() => {
@@ -96,8 +130,7 @@ export default function LoginForm({
try {
const rawNext = magicLinkNextPath ?? nextParam;
const nextPath = safeInternalPath(rawNext);
const shouldAttachDraft =
isSaveProgress || nextPath.includes("syncDraft=1");
const shouldAttachDraft = shouldAttachCreateFlowDraft(variant, nextPath);
const localDraft = readAnonymousCreateFlowState();
const draft =
shouldAttachDraft && Object.keys(localDraft).length > 0
@@ -115,7 +148,7 @@ export default function LoginForm({
}
return;
}
if (isSaveProgress || nextPath.includes("syncDraft=1")) {
if (shouldAttachDraft) {
setTransferPendingFlag();
}
setEmail(trimmed);
@@ -127,11 +160,11 @@ export default function LoginForm({
}
}, [
email,
isSaveProgress,
magicLinkNextPath,
nextParam,
stripErrorQuery,
t,
variant,
]);
const urlErrorMessage =
@@ -155,20 +188,8 @@ export default function LoginForm({
</div>
<ContentLockup
titleId={titleId}
title={
sent
? t("successTitle")
: isSaveProgress
? t("saveProgressTitle")
: t("title")
}
description={
sent
? t("successBody")
: isSaveProgress
? t("saveProgressSubtitle")
: t("subtitle")
}
title={heading.title}
description={heading.description}
variant="login"
alignment="left"
/>
@@ -255,6 +276,19 @@ export default function LoginForm({
>
{t("sendMagicLink")}
</Button>
{onDismiss ? (
<Button
type="button"
size="large"
buttonType="ghost"
palette="default"
disabled={submitting}
onClick={onDismiss}
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
>
{t("continueWithoutSaving")}
</Button>
) : null}
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")}
<Link
@@ -14,12 +14,14 @@ const ModalFooterContainer = memo<ModalFooterProps>((props) => {
const t = useTranslation("common");
const resolvedBackText = props.backButtonText ?? t("buttons.back");
const resolvedNextText = props.nextButtonText ?? t("buttons.next");
const resolvedRemoveText = props.removeButtonText ?? t("buttons.remove");
return (
<ModalFooterView
{...props}
backButtonText={resolvedBackText}
nextButtonText={resolvedNextText}
removeButtonText={resolvedRemoveText}
/>
);
});
@@ -1,8 +1,14 @@
export interface ModalFooterProps {
showBackButton?: boolean;
showNextButton?: boolean;
/**
* Danger **Remove** in the left footer slot (selected create-flow modules).
* Takes the left slot instead of Back when both would otherwise show.
*/
showRemoveButton?: boolean;
onBack?: () => void;
onNext?: () => void;
onRemove?: () => void;
/**
* Custom back button text. If not provided, uses localized "Back" from common.json
*/
@@ -11,6 +17,10 @@ export interface ModalFooterProps {
* Custom next button text. If not provided, uses localized "Next" from common.json
*/
nextButtonText?: string;
/**
* Custom remove button text. If not provided, uses localized "Remove" from common.json
*/
removeButtonText?: string;
nextButtonDisabled?: boolean;
currentStep?: number;
totalSteps?: number;
@@ -7,10 +7,13 @@ import type { ModalFooterProps } from "./ModalFooter.types";
export function ModalFooterView({
showBackButton = false,
showNextButton = false,
showRemoveButton = false,
onBack,
onNext,
onRemove,
backButtonText,
nextButtonText,
removeButtonText,
nextButtonDisabled = false,
currentStep,
totalSteps,
@@ -22,12 +25,26 @@ export function ModalFooterView({
stepperProp !== undefined
? stepperProp
: currentStep !== undefined && totalSteps !== undefined;
const showStartBack = showBackButton && !showRemoveButton;
return (
<div
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
>
{showBackButton && (
{showRemoveButton && (
<div className="absolute left-[16px] top-[12px]">
<Button
buttonType="danger"
palette="default"
size="medium"
onClick={onRemove}
>
{removeButtonText}
</Button>
</div>
)}
{showStartBack && (
<div className="absolute left-[16px] top-[12px]">
<Button
buttonType="outline"
@@ -97,9 +97,10 @@ const RuleStackContainer = memo<RuleStackProps>(
}
}
logger.debug(`${slug} template clicked`);
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
// (local + server draft when sync) so stale state cannot break template apply.
prepareFreshCreateFlowEntrySync();
// Marketing home “Popular templates”: same fresh start as Top “Create rule”.
// `signedIn: true` because this surface has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
};
+15 -1
View File
@@ -11,7 +11,7 @@ import {
import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm";
export type AuthModalLoginVariant = "default" | "saveProgress";
export type AuthModalLoginVariant = "default" | "saveProgress" | "keepRule";
export type AuthModalBackdropVariant = "solid" | "blurredYellow";
@@ -20,6 +20,11 @@ export type OpenLoginOptions = {
/** Passed to `requestMagicLink` as `next` (internal path). */
nextPath?: string;
backdropVariant?: AuthModalBackdropVariant;
/**
* `keepRule` only: **Continue without saving**. Default is close the overlay.
* Guest completed Save & Exit passes leave-the-flow.
*/
onDismiss?: () => void;
};
type AuthModalContextValue = {
@@ -49,6 +54,14 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
);
const backdropVariant = opts.backdropVariant ?? "blurredYellow";
const keepRuleDismiss =
opts.variant === "keepRule"
? () => {
const extra = opts.onDismiss;
closeLogin();
extra?.();
}
: undefined;
return (
<AuthModalContext.Provider value={value}>
@@ -63,6 +76,7 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
<LoginForm
variant={opts.variant ?? "default"}
magicLinkNextPath={opts.nextPath}
onDismiss={keepRuleDismiss}
/>
</Login>
</AuthModalContext.Provider>
+15 -7
View File
@@ -61,7 +61,7 @@ The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PU
### Fresh start vs continue draft (signed-in + sync)
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Signed-in users additionally get **Save & Exit**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Both see **Save & Exit** from `community-structure` onward (and `edit-rule`). On **`/create/completed`**, guests still get **Save & Exit** (keep-this-rule magic-link modal); signed-in users get **Exit**. Wizard guests open the save-progress magic-link modal; signed-in users may **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped.
- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path.
@@ -79,9 +79,17 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
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` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Exit on template preview.** Direct catalog / marketing entry (`/create/review-template/[slug]` without `?fromFlow=1`) has nothing to save — top-nav **Exit** clears client state and goes home, with no save-progress modal and no leave confirm. In-flow (`?fromFlow=1`) still has community progress, so Exit keeps the usual save / leave-confirm path.
**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` 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.
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and skips custom-rule authoring. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
**Entering a template before community stage is done.** Both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` when the community questionnaire still needs to run.
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)**`/create/review``/templates?fromFlow=1` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
**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. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the users community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror.
@@ -89,9 +97,9 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
| --- | --- | --- |
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]` |
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]?fromFlow=1` |
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.
Only one `?fromFlow=1` marker exists on `/templates`. Template clicks forward it onto `/create/review-template/[slug]?fromFlow=1` so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
@@ -105,8 +113,8 @@ Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templat
| Mode | Where progress lives | Save & Exit / publish |
| --- | --- | --- |
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 45 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). |
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. |
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Save & Exit** (from `community-structure` onward, plus `edit-rule`) opens the save-progress magic-link modal; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 45 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Publish** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. Landing on **`/create/completed`** opens a **keep-this-rule** magic-link modal (no draft transfer); the top nav stays **Save & Exit** so they can reopen it (**Continue without saving** from that control leaves the flow). After Publish, skip on the auto-opened modal stays on completed. **Edit** shows when a last-published rule is in session storage. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). |
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (and `edit-rule`) may **PUT** `/api/drafts/me` when sync is on. **Completed** is **Exit**. **Publish** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. |
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
+3 -3
View File
@@ -218,19 +218,19 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts).
**Context:** [lib/create/api.ts](lib/create/api.ts) wraps `POST /api/rules` with Zod-validated body (Ticket 2). Finalize flows through [useCreateFlowFinalize](app/(app)/create/hooks/useCreateFlowFinalize.ts) from [CreateFlowLayoutClient](app/(app)/create/CreateFlowLayoutClient.tsx) (`final-review``publishRule``/create/completed`).
**Context:** [lib/create/api.ts](lib/create/api.ts) wraps `POST /api/rules` with Zod-validated body (Ticket 2). Publish flows through [useCreateFlowFinalize](app/(app)/create/hooks/useCreateFlowFinalize.ts) from [CreateFlowLayoutClient](app/(app)/create/CreateFlowLayoutClient.tsx) (`final-review``publishRule``/create/completed`).
**Implementation (shipped):**
1. Map `CreateFlowState``title` / `summary` / `document` via [buildPublishPayload](lib/create/buildPublishPayload.ts) (and related builders).
2. Call `publishRule` on explicit **Finalize** from `final-review` ([useCreateFlowFinalize](app/(app)/create/hooks/useCreateFlowFinalize.ts)).
2. Call `publishRule` on explicit **Publish** from `final-review` ([useCreateFlowFinalize](app/(app)/create/hooks/useCreateFlowFinalize.ts)).
3. **401**`openLogin` with return path (Ticket 3 / `AuthModalProvider`).
4. Success: navigate to `completed` with rule id in query string.
**Acceptance criteria:**
- [x] Published row appears in Postgres (`PublishedRule`) and `GET /api/rules` lists it.
- [x] User sees clear success/failure (banner / flow state; see finalize hook).
- [x] User sees clear success/failure (banner / flow state; see publish hook).
**Files:** [app/(app)/create/hooks/useCreateFlowFinalize.ts](app/(app)/create/hooks/useCreateFlowFinalize.ts), [CreateFlowLayoutClient.tsx](app/(app)/create/CreateFlowLayoutClient.tsx), [app/api/rules/route.ts](app/api/rules/route.ts), [lib/create/api.ts](lib/create/api.ts), [lib/create/buildPublishPayload.ts](lib/create/buildPublishPayload.ts).
+14 -8
View File
@@ -255,8 +255,13 @@ 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
without changes** writes `template.body.sections` into `state.sections`.
Direct catalog / marketing entry routes to `community-name` and Next/Back
collect only name, description, and optional photo, then
`confirm-stakeholders`. A leftover draft title does not skip those
identity steps. In-flow (`?fromFlow=1` after the community questionnaire)
skips identity and opens `confirm-stakeholders`. The user
exits via the normal
`final-review → handleFinalize → publishRule` path and picks up the
server-enforced 401 sign-in gate for free.
@@ -264,12 +269,13 @@ 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
on `CreateFlowState`. Customize then routes to `informational`. Direct
Use without changes routes to `community-name` (identity-only skip list
while the pin is set). Once the user reaches `/create/review`, `CommunityReviewScreen`
consumes the pin and `router.replace`s past itself — to `core-values` for
`customize`, to `community-name` 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.
---
+22
View File
@@ -0,0 +1,22 @@
/**
* Guest publish `/create/completed` keep-this-rule login. Stored in
* sessionStorage so the prompt survives create-layout remounts and is not
* tied to the Publish click (that click would otherwise dismiss the overlay).
*/
export const CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY =
"createFlow.pendingKeepRuleLogin";
export function writePendingKeepRuleLogin(): void {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY, "1");
}
export function hasPendingKeepRuleLogin(): boolean {
if (typeof sessionStorage === "undefined") return false;
return sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY) === "1";
}
export function clearPendingKeepRuleLogin(): void {
if (typeof sessionStorage === "undefined") return;
sessionStorage.removeItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY);
}
+1
View File
@@ -7,6 +7,7 @@
"askOrganizer": "Ask an organizer",
"back": "Back",
"next": "Next",
"remove": "Remove",
"finish": "Finish"
},
"ariaLabels": {
+1 -1
View File
@@ -9,7 +9,7 @@
"confirmDetails": "Confirm details",
"confirmDescription": "Confirm description",
"confirmMembers": "Confirm members",
"finalizeCommunityRule": "Finalize CommunityRule",
"publishCommunityRule": "Publish CommunityRule",
"confirmStakeholders": "Confirm Stakeholders",
"confirmCoreValues": "Confirm values",
"confirmCommunication": "Confirm",
@@ -18,5 +18,5 @@
"guestInvitesSkippedTitle": "Stakeholder invites were not sent",
"guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.",
"guestClaimHintTitle": "Sign in to keep this rule on your profile",
"guestClaimHintDescription": "Use Log in on this same browser to attach the rule to your account so you can edit it later."
"guestClaimHintDescription": "Sign in on this same browser to attach the rule to your account so you can edit it later."
}
@@ -1,8 +1,8 @@
{
"title": "Review your CommunityRule",
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
"description": "Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again.",
"editPublishedTitle": "Edit your CommunityRule",
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Finalize applies changes to your published CommunityRule.",
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.",
"ruleCardTitleFallback": "Your community",
"chipEditModal": {
"saveButton": "Save",
@@ -1,6 +1,6 @@
{
"finalizeBannerTitle": "Couldn't publish",
"missingCommunityName": "Add a community name before finalizing.",
"finalizeButtonPublishing": "Publishing…",
"publishBannerTitle": "Couldn't publish",
"missingCommunityName": "Add a community name before publishing.",
"publishButtonPublishing": "Publishing…",
"genericPublishFailed": "Something went wrong. Try again."
}
+3
View File
@@ -3,6 +3,9 @@
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
"saveProgressTitle": "Save your progress?",
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.",
"keepRuleTitle": "Sign in to keep this rule on your profile",
"keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.",
"continueWithoutSaving": "Continue without saving",
"emailLabel": "Email address",
"emailPlaceholder": "you@example.com",
"sendMagicLink": "Send me a magic link",
+25
View File
@@ -29,6 +29,9 @@ export default {
showNextButton: {
control: { type: "boolean" },
},
showRemoveButton: {
control: { type: "boolean" },
},
nextButtonDisabled: {
control: { type: "boolean" },
},
@@ -196,6 +199,28 @@ export const LoginYellowBackdrop = {
},
render: Template,
};
export const SelectedModuleRemove = {
args: {
isOpen: true,
title: "Lazy Consensus",
description:
"A decision is assumed approved unless objections are raised within a specified timeframe.",
backdropVariant: "blurredYellow",
children: (
<div className="space-y-4">
<p className="text-[var(--color-content-default-primary)]">
Selected module: danger Remove on the left, Save on the right.
</p>
</div>
),
showBackButton: false,
showRemoveButton: true,
showNextButton: true,
nextButtonText: "Save",
nextButtonDisabled: false,
},
render: Template,
};
export const NextButtonDisabled = {
args: {
isOpen: true,
+15
View File
@@ -11,6 +11,11 @@ export default {
control: "boolean",
description: "Whether to render the back button on the left",
},
showRemoveButton: {
control: "boolean",
description:
"Whether to render a danger Remove button in the left footer slot",
},
showNextButton: {
control: "boolean",
description: "Whether to render the next button on the right",
@@ -41,6 +46,7 @@ export default {
},
onBack: { action: "back-clicked" },
onNext: { action: "next-clicked" },
onRemove: { action: "remove-clicked" },
},
};
@@ -69,3 +75,12 @@ export const NextOnly = {
showNextButton: true,
},
};
export const RemoveAndSave = {
args: {
showBackButton: false,
showRemoveButton: true,
showNextButton: true,
nextButtonText: "Save",
},
};
+1 -1
View File
@@ -8,7 +8,7 @@ export default {
docs: {
description: {
component:
"Pre-finalize review: HeaderLockup + expanded Rule sections.",
"Pre-publish review: HeaderLockup + expanded Rule sections.",
},
},
},
+2 -2
View File
@@ -84,7 +84,7 @@ describe("CompletedScreen", () => {
expect(screen.getByText("Fixture value title")).toBeInTheDocument();
});
it("does not show post-finalize toast without celebrate query", () => {
it("does not show post-publish toast without celebrate query", () => {
render(<CompletedScreen />);
expect(
screen.queryByText(
@@ -98,7 +98,7 @@ describe("CompletedScreen", () => {
).not.toBeInTheDocument();
});
it("shows post-finalize toast in status region when celebrate query is set", () => {
it("shows post-publish toast in status region when celebrate query is set", () => {
mockSearchParams({
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
@@ -27,6 +27,9 @@ describe("ConflictManagementScreen", () => {
for (const field of fields) {
expect(field).toBeEnabled();
}
expect(
within(dialog).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
@@ -26,6 +26,9 @@ describe("CoreValuesSelectScreen", () => {
expect(
within(dialog).getByRole("button", { name: "Add Value" }),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
expect(
screen.getByRole("menuitem", { name: "Customize" }),
@@ -124,6 +127,9 @@ describe("CoreValuesSelectScreen", () => {
});
fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog");
expect(
within(editing).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
fireEvent.click(
within(editing).getByRole("button", { name: "More options" }),
);
+35
View File
@@ -169,6 +169,41 @@ describe("Create", () => {
expect(screen.getByText("Custom Footer")).toBeInTheDocument();
});
it("renders a danger Remove in the left footer when showRemoveButton is true", () => {
const onRemove = vi.fn();
renderWithProviders(
<Create
{...defaultProps}
showBackButton={false}
showRemoveButton
onRemove={onRemove}
showNextButton
nextButtonText="Save"
/>,
);
const removeButton = screen.getByRole("button", { name: "Remove" });
expect(removeButton).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back" })).not.toBeInTheDocument();
fireEvent.click(removeButton);
expect(onRemove).toHaveBeenCalledTimes(1);
});
it("prefers Remove over Back when both would occupy the left slot", () => {
renderWithProviders(
<Create
{...defaultProps}
showBackButton
showRemoveButton
onBack={vi.fn()}
onRemove={vi.fn()}
showNextButton
nextButtonText="Save"
/>,
);
expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back" })).not.toBeInTheDocument();
});
it("uses responsive width at baseline (matches Login modal)", () => {
renderWithProviders(
<Create {...defaultProps}>Create dialog content</Create>,
+44 -3
View File
@@ -79,7 +79,7 @@ describe("FinalReviewScreen", () => {
render(<FinalReviewScreen />);
expect(
screen.getByText(
/Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again./i,
/Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again./i,
),
).toBeInTheDocument();
});
@@ -519,7 +519,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
).not.toBeInTheDocument();
});
it("closes the chip edit modal when Back is pressed", async () => {
it("closes the chip edit modal when the close control is pressed", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {}}
@@ -532,12 +532,41 @@ describe("FinalReviewScreen — chip detail modal", () => {
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Back" }));
expect(
within(dialog).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Back" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByLabelText("Close dialog"));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("deselects a method chip from the footer Remove", async () => {
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: ["signal"],
}}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(latest.selectedCommunicationMethodIds ?? []).not.toContain("signal");
});
});
/**
@@ -975,6 +1004,18 @@ function FinalReviewEditPublishedWithStateProbe({
}
describe("FinalReviewScreen — edit published title and description", () => {
it("renders edit-published lockup copy", () => {
render(<FinalReviewScreen variant="editPublished" />);
expect(
screen.getByRole("heading", { name: "Edit your CommunityRule" }),
).toBeInTheDocument();
expect(
screen.getByText(
/Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule./i,
),
).toBeInTheDocument();
});
it("does not expose click-to-edit title or description on default final review", () => {
render(
<FinalReviewWithFlowState
+20
View File
@@ -110,6 +110,26 @@ describe("Login", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes on backdrop pointerdown, not a leftover click", async () => {
const onClose = vi.fn();
renderWithProviders(
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const backdrop = screen.getByRole("dialog").parentElement;
expect(backdrop).not.toBeNull();
fireEvent.click(backdrop!);
expect(onClose).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole("dialog"));
expect(onClose).not.toHaveBeenCalled();
fireEvent.pointerDown(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("locks body scroll while open", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
+64
View File
@@ -174,6 +174,70 @@ describe("LoginForm", () => {
expect(setTransferPendingFlag).toHaveBeenCalled();
});
it("keepRule variant claims without attaching a create-flow draft", async () => {
const user = userEvent.setup();
window.localStorage.setItem(
"create-flow-anonymous",
JSON.stringify({ title: "Guest draft" }),
);
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="keepRule"
magicLinkNextPath="/create/completed"
/>
</Suspense>,
);
expect(
screen.getByRole("heading", {
name: /sign in to keep this rule on your profile/i,
}),
).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"guest@example.com",
"/create/completed",
undefined,
);
});
expect(setTransferPendingFlag).not.toHaveBeenCalled();
window.localStorage.removeItem("create-flow-anonymous");
});
it("keepRule continue without saving calls onDismiss", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="keepRule"
magicLinkNextPath="/create/completed"
onDismiss={onDismiss}
/>
</Suspense>,
);
await user.click(
screen.getByRole("button", { name: /continue without saving/i }),
);
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(requestMagicLink).not.toHaveBeenCalled();
});
it("default variant has no continue without saving action", () => {
renderLoginForm();
expect(
screen.queryByRole("button", { name: /continue without saving/i }),
).not.toBeInTheDocument();
});
it("passes safe next path when next query param is set", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("next=/learn");
@@ -27,6 +27,9 @@ describe("MembershipMethodsScreen", () => {
for (const field of fields) {
expect(field).toBeEnabled();
}
expect(
within(dialog).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
+2 -4
View File
@@ -104,12 +104,10 @@ describe("CommunityReviewScreen — pendingTemplateAction redirect", () => {
expect(testRouter.push).not.toHaveBeenCalled();
});
it("redirects to /create/confirm-stakeholders when mode === 'useWithoutChanges'", async () => {
it("redirects Use without changes to community-name so identity is not skipped", async () => {
render(<ReviewWithPendingAction mode="useWithoutChanges" />);
await waitFor(() => {
expect(testRouter.replace).toHaveBeenCalledWith(
"/create/confirm-stakeholders",
);
expect(testRouter.replace).toHaveBeenCalledWith("/create/community-name");
});
expect(testRouter.push).not.toHaveBeenCalled();
});
+105 -1
View File
@@ -1,4 +1,4 @@
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -47,6 +47,7 @@ import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDr
function LoginTrigger() {
const { openLogin, closeLogin } = useAuthModal();
const [leftCompleted, setLeftCompleted] = useState(false);
return (
<div>
<button type="button" onClick={() => openLogin()}>
@@ -63,9 +64,33 @@ function LoginTrigger() {
>
Open save progress
</button>
<button
type="button"
onClick={() =>
openLogin({
variant: "keepRule",
nextPath: "/create/completed",
})
}
>
Open keep rule
</button>
<button
type="button"
onClick={() =>
openLogin({
variant: "keepRule",
nextPath: "/create/completed",
onDismiss: () => setLeftCompleted(true),
})
}
>
Open keep rule then leave
</button>
<button type="button" onClick={() => closeLogin()}>
Close from outside
</button>
{leftCompleted ? <p>Left completed</p> : null}
</div>
);
}
@@ -133,6 +158,9 @@ describe("AuthModalProvider (header overlay)", () => {
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /continue without saving/i }),
).not.toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
@@ -149,4 +177,80 @@ describe("AuthModalProvider (header overlay)", () => {
});
expect(setTransferPendingFlag).toHaveBeenCalled();
});
it("keepRule openLogin does not set transfer pending", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(screen.getByRole("button", { name: /^open keep rule$/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(
screen.getByRole("heading", {
name: /sign in to keep this rule on your profile/i,
}),
).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"guest@example.com",
"/create/completed",
undefined,
);
});
expect(setTransferPendingFlag).not.toHaveBeenCalled();
});
it("keepRule continue without saving closes the overlay", async () => {
const user = userEvent.setup();
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(screen.getByRole("button", { name: /^open keep rule$/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: /continue without saving/i }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(screen.queryByText("Left completed")).not.toBeInTheDocument();
});
it("keepRule onDismiss runs when continue without saving is pressed", async () => {
const user = userEvent.setup();
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(
screen.getByRole("button", { name: /open keep rule then leave/i }),
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: /continue without saving/i }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(screen.getByText("Left completed")).toBeInTheDocument();
});
});
+27 -3
View File
@@ -40,7 +40,7 @@ describe("Create flow communication-methods page", () => {
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
});
test("re-opening a selected method shows Save; Remove is in the kebab", async () => {
test("re-opening a selected method shows Save and footer Remove; Remove stays in the kebab", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
@@ -54,8 +54,8 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
expect(
within(dialogAgain).queryByRole("button", { name: "Add Platform" }),
).not.toBeInTheDocument();
@@ -67,6 +67,30 @@ describe("Create flow communication-methods page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
});
test("Remove from the footer deselects the method", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
});
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Add Platform",
}),
);
expect(signalCards[0]).toHaveTextContent("SELECTED");
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
});
test("Remove from the kebab deselects the method", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
+27 -3
View File
@@ -244,7 +244,7 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("re-opening a selected approach shows Save; Remove is in the kebab", async () => {
test("re-opening a selected approach shows Save and footer Remove; Remove stays in the kebab", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
@@ -262,8 +262,8 @@ describe("Create flow decision-approaches page", () => {
await user.click(card);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
expect(
within(dialogAgain).queryByRole("button", { name: "Add Approach" }),
).not.toBeInTheDocument();
@@ -275,6 +275,30 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
});
test("Remove from the footer deselects the approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add Approach",
}),
);
expect(card).toHaveTextContent("SELECTED");
await user.click(card);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(card).not.toHaveTextContent("SELECTED");
});
test("Save on a selected approach persists the edit and closes", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
+6
View File
@@ -19,4 +19,10 @@ describe("create footer messages", () => {
it("exposes confirmMembers for the community-size footer CTA", () => {
expect(messages.create.footer.confirmMembers).toBe("Confirm members");
});
it("exposes publishCommunityRule for the final-review footer CTA", () => {
expect(messages.create.footer.publishCommunityRule).toBe(
"Publish CommunityRule",
);
});
});
+89
View File
@@ -8,6 +8,8 @@ import {
getStepIndex,
parseReviewReturnSearchParam,
resolveCreateFlowBackTarget,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
@@ -81,6 +83,31 @@ describe("flowSteps", () => {
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
it("useWithoutChangesIdentityOnly walks name → description → photo → stakeholders", () => {
const opts = { useWithoutChangesIdentityOnly: true } as const;
expect(getNextStep("community-name", opts)).toBe("community-context");
expect(getNextStep("community-context", opts)).toBe("community-upload");
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review");
expect(getPreviousStep("community-context", opts)).toBe("community-name");
expect(getPreviousStep("community-upload", opts)).toBe("community-context");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
expect(getPreviousStep("community-name", opts)).toBeNull();
});
it("useWithoutChangesIdentityOnly composes with skipCommunitySave", () => {
const opts = {
skipCommunitySave: true,
useWithoutChangesIdentityOnly: true,
} as const;
expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders");
expect(getPreviousStep("confirm-stakeholders", opts)).toBe(
"community-upload",
);
});
it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => {
expect(
resolveCreateFlowBackTarget(
@@ -91,6 +118,26 @@ describe("flowSteps", () => {
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
});
it("resolveCreateFlowBackTarget sends identity-only community-name back to template review", () => {
expect(
resolveCreateFlowBackTarget(
"community-name",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" });
});
it("resolveCreateFlowBackTarget uses photo step behind stakeholders on the identity-only path", () => {
expect(
resolveCreateFlowBackTarget(
"confirm-stakeholders",
{ useWithoutChangesIdentityOnly: true },
"mutual-aid-mondays",
),
).toEqual({ kind: "step", step: "community-upload" });
});
it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => {
expect(
resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined),
@@ -132,4 +179,46 @@ describe("flowSteps", () => {
).toBeNull();
expect(parseReviewReturnSearchParam(null)).toBeNull();
});
it("offers Save & Exit from community-structure through final-review and on edit-rule", () => {
expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("final-review")).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit("edit-rule")).toBe(true);
});
it("offers Save & Exit on completed only for guests", () => {
expect(shouldOfferCreateFlowSaveAndExit("completed")).toBe(false);
expect(
shouldOfferCreateFlowSaveAndExit("completed", { id: "user-1" }),
).toBe(false);
expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true);
expect(shouldOfferCreateFlowSaveAndExit(null)).toBe(false);
});
it("isDirectTemplateReviewEntry is true only on template preview without fromFlow", () => {
expect(
isDirectTemplateReviewEntry("/create/review-template/consensus"),
).toBe(true);
expect(
isDirectTemplateReviewEntry(
"/create/review-template/consensus",
new URLSearchParams(),
),
).toBe(true);
expect(
isDirectTemplateReviewEntry(
"/create/review-template/consensus",
new URLSearchParams("fromFlow=1"),
),
).toBe(false);
expect(
isDirectTemplateReviewEntry(
"/create/community-name",
new URLSearchParams(),
),
).toBe(false);
expect(isDirectTemplateReviewEntry(null)).toBe(false);
});
});
@@ -4,6 +4,7 @@ import type { CreateFlowState } from "../../../app/(app)/create/types";
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
import { publishRule, updatePublishedRule } from "../../../lib/create/api";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin";
import {
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
@@ -44,6 +45,7 @@ describe("useCreateFlowFinalize", () => {
updateState.mockReset();
openLogin.mockReset();
onGuestPublished.mockReset();
sessionStorage.clear();
});
afterEach(() => {
@@ -82,6 +84,9 @@ describe("useCreateFlowFinalize", () => {
summary: "Published summary",
document: {},
});
expect(
sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY),
).toBeNull();
});
it("does not publish while the session is unresolved", async () => {
@@ -137,6 +142,7 @@ describe("useCreateFlowFinalize", () => {
});
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true });
expect(openLogin).not.toHaveBeenCalled();
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
expect(router.push).toHaveBeenCalledWith(
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
);
@@ -172,6 +178,7 @@ describe("useCreateFlowFinalize", () => {
});
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
expect(openLogin).not.toHaveBeenCalled();
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
expect(router.push).toHaveBeenCalledWith(
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
);
@@ -250,4 +257,69 @@ describe("useCreateFlowFinalize", () => {
editingPublishedRuleId: undefined,
});
});
it("does not PATCH when a guest publishes an already-published rule", async () => {
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: {
...emptyState,
editingPublishedRuleId: "guest-rule-1",
},
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review?syncDraft=1",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(updatePublishedRule).not.toHaveBeenCalled();
expect(publishRule).not.toHaveBeenCalled();
expect(openLogin).not.toHaveBeenCalled();
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
expect(writeLastPublishedRule).toHaveBeenCalledWith({
id: "guest-rule-1",
title: "Published title",
summary: "Published summary",
document: {},
});
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
expect(router.push).toHaveBeenCalledWith("/create/completed");
});
it("opens keepRule login when a guest publish is unauthorized", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: false,
error: "Unauthorized",
status: 401,
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: emptyState,
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review?syncDraft=1",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(openLogin).toHaveBeenCalledWith({
variant: "keepRule",
nextPath: "/create/completed",
backdropVariant: "blurredYellow",
});
expect(router.push).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,135 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CreateFlowState } from "../../../app/(app)/create/types";
import { useTemplateReviewActions } from "../../../app/(app)/create/hooks/useTemplateReviewActions";
import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug";
vi.mock("../../../lib/create/loadTemplateReviewBySlug", () => ({
loadTemplateReviewBySlug: vi.fn(),
}));
const templateBody = {
sections: [
{
categoryName: "Communication",
entries: [{ title: "Signal" }],
},
],
};
const loadedTemplate = {
ok: true as const,
template: {
id: "t1",
slug: "mutual-aid-mondays",
title: "Mutual Aid Mondays",
category: null,
description: null,
body: templateBody,
sortOrder: 0,
featured: false,
},
};
describe("useTemplateReviewActions", () => {
const router = { push: vi.fn() };
const updateState = vi.fn();
const markCreateFlowInteraction = vi.fn();
let applied: CreateFlowState | undefined;
const replaceState = vi.fn(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({});
},
);
beforeEach(() => {
vi.mocked(loadTemplateReviewBySlug).mockReset();
vi.mocked(loadTemplateReviewBySlug).mockResolvedValue(loadedTemplate);
router.push.mockReset();
updateState.mockReset();
markCreateFlowInteraction.mockReset();
replaceState.mockClear();
applied = undefined;
});
function renderActions(
state: CreateFlowState,
fromCreateWizard = false,
) {
return renderHook(() =>
useTemplateReviewActions({
pathname: "/create/review-template/mutual-aid-mondays",
state,
updateState,
replaceState,
router,
fromCreateWizard,
markCreateFlowInteraction,
}),
);
}
it("direct Use without changes walks identity from community-name", async () => {
const { result } = renderActions({});
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(markCreateFlowInteraction).toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith("/create/community-name");
expect(applied?.pendingTemplateAction).toEqual({
slug: "mutual-aid-mondays",
mode: "useWithoutChanges",
});
});
it("direct Use without changes ignores a leftover draft title", async () => {
replaceState.mockImplementation(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({
title: "Neighborhood",
communityContext: "Stale description",
currentStep: "confirm-stakeholders",
});
},
);
const { result } = renderActions({ title: "Neighborhood" });
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/community-name");
expect(applied?.title).toBeUndefined();
expect(applied?.communityContext).toBeUndefined();
expect(applied?.currentStep).toBeUndefined();
expect(applied?.pendingTemplateAction?.mode).toBe("useWithoutChanges");
});
it("in-flow Use without changes skips identity and keeps the community name", async () => {
replaceState.mockImplementation(
(updater: (_prev: CreateFlowState) => CreateFlowState) => {
applied = updater({
title: "Neighborhood",
communityContext: "We meet weekly",
templateReviewEntryFromCreateFlow: true,
});
},
);
const { result } = renderActions(
{ title: "Neighborhood", templateReviewEntryFromCreateFlow: true },
true,
);
await act(async () => {
await result.current.handleUseWithoutChanges();
});
expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders");
expect(applied?.title).toBe("Neighborhood");
expect(applied?.communityContext).toBe("We meet weekly");
expect(applied?.pendingTemplateAction).toBeUndefined();
expect(applied?.templateReviewBackSlug).toBe("mutual-aid-mondays");
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY,
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
writePendingKeepRuleLogin,
} from "../../lib/create/pendingKeepRuleLogin";
describe("pendingKeepRuleLogin", () => {
beforeEach(() => {
sessionStorage.clear();
});
it("writes, reads, and clears the session flag", () => {
expect(hasPendingKeepRuleLogin()).toBe(false);
writePendingKeepRuleLogin();
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe(
"1",
);
expect(hasPendingKeepRuleLogin()).toBe(true);
clearPendingKeepRuleLogin();
expect(hasPendingKeepRuleLogin()).toBe(false);
});
});