diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 2f5812b..983136f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -11,6 +11,8 @@ import { usePathname, useRouter } from "next/navigation"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; +import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; +import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav"; import { getNextStep, getStepIndex } from "./utils/flowSteps"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; @@ -20,11 +22,9 @@ import { } from "./utils/createFlowScreenRegistry"; import CreateFlowFooter from "../../components/utility/CreateFlowFooter"; import Button from "../../components/buttons/Button"; -import { buildPublishPayload } from "../../../lib/create/buildPublishPayload"; import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail"; import { fetchAuthSession, - publishRule, requestMagicLink, } from "../../../lib/create/api"; import { safeInternalPath } from "../../../lib/safeInternalPath"; @@ -33,12 +33,6 @@ import { setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; import { deleteServerDraft } from "../../../lib/create/api"; -import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -import { - buildCoreValuesPrefillFromTemplateBody, - buildTemplateCustomizePrefill, -} from "../../../lib/create/applyTemplatePrefill"; -import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug"; import messages from "../../../messages/en/index"; import { CREATE_FLOW_FOOTER_BUTTON_CLASS, @@ -48,6 +42,7 @@ import { CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, type CustomRuleConfirmFooterStep, } from "./utils/customRuleConfirmFooterSteps"; +import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels"; import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; @@ -86,12 +81,14 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) { return ( - - {children} - + + + {children} + + ); @@ -120,6 +117,7 @@ function CreateFlowLayoutContent({ previousStep, goToNextStep, goToPreviousStep, + templateReviewFooterBackToCreateReview, } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); @@ -127,14 +125,6 @@ function CreateFlowLayoutContent({ useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); - const [publishBannerMessage, setPublishBannerMessage] = useState< - string | null - >(null); - const [isPublishing, setIsPublishing] = useState(false); - const [templateReviewApplyError, setTemplateReviewApplyError] = useState< - string | null - >(null); - const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = useState(false); const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState< @@ -143,211 +133,28 @@ function CreateFlowLayoutContent({ const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = useState(false); - const templateReviewMatch = pathname?.match( - /\/create\/review-template\/([^/?#]+)/, - ); - const templateReviewSlug = templateReviewMatch?.[1] - ? decodeURIComponent(templateReviewMatch[1]) - : null; - /** Match anywhere in path so locale/basePath variants still get template footer + layout. */ - const isTemplateReviewRoute = Boolean( - pathname?.includes("/create/review-template/"), - ); + const { + publishBannerMessage, + setPublishBannerMessage, + isPublishing, + finalize: handleFinalize, + } = useCreateFlowFinalize({ state, router, openLogin }); - const handleFinalize = useCallback(async () => { - setPublishBannerMessage(null); - const payloadResult = buildPublishPayload(state); - if (payloadResult.ok === false) { - setPublishBannerMessage( - payloadResult.error === "missingCommunityName" - ? messages.create.reviewAndComplete.publish.missingCommunityName - : payloadResult.error, - ); - return; - } - const { title, summary, document: ruleDocument } = payloadResult; - setIsPublishing(true); - const publishResult = await publishRule({ - title, - summary, - document: ruleDocument, - }); - setIsPublishing(false); - if (publishResult.ok === true) { - writeLastPublishedRule({ - id: publishResult.id, - title, - summary: summary ?? null, - document: ruleDocument, - }); - router.push("/create/completed"); - return; - } - if (publishResult.status === 401) { - openLogin({ - variant: "default", - nextPath: "/create/final-review?syncDraft=1", - backdropVariant: "blurredYellow", - }); - return; - } - setPublishBannerMessage( - publishResult.error.trim() !== "" - ? publishResult.error - : messages.create.reviewAndComplete.publish.genericPublishFailed, - ); - }, [state, router, openLogin]); - - /** - * Customize flow from a template-review page. Applies the template's - * customize selections onto `CreateFlowState` so the custom-rule screens - * render with chips pre-highlighted, then routes to `core-values` once - * the community name is set — otherwise to `informational` with a - * `pendingTemplateAction` pin so `/create/review` later redirects past - * itself straight to `core-values` (see `CommunityReviewScreen`). - * - * Why title alone? Other community-stage fields (e.g. - * `communityStructureChipSnapshots`) are sticky once the user lands on - * those screens, so they can't reliably answer "has the user given us - * real input yet?". A non-empty community name is the minimum bar - * `buildPublishPayload` already enforces — we reuse that here. - * - * Direct entry (marketing home "Popular templates" or `/templates` - * landed on directly) wipes the anonymous draft at the *click site* via - * `clearCreateFlowPersistedDrafts` before navigating, so `state.title` - * is empty here and the no-community branch fires naturally. No - * URL-marker plumbing needed in this handler. - */ - const handleCustomizeTemplate = useCallback(async () => { - if (!templateReviewSlug) return; - setTemplateReviewApplyError(null); - setIsApplyingTemplate(true); - const loaded = await loadTemplateReviewBySlug(templateReviewSlug); - setIsApplyingTemplate(false); - if (loaded.ok === false) { - setTemplateReviewApplyError(loaded.message); - return; - } - const prefill = buildTemplateCustomizePrefill(loaded.template.body); - const hasCommunityName = - typeof state.title === "string" && state.title.trim().length > 0; - // Prefill merges (shallow) with current state. When we have to bounce the - // user to the community stage first, pin a pendingTemplateAction so - // `/create/review` knows to skip past itself to `core-values` later. - updateState({ - ...prefill, - ...(hasCommunityName - ? { pendingTemplateAction: undefined } - : { - pendingTemplateAction: { - slug: templateReviewSlug, - mode: "customize", - }, - }), - }); - router.push( - hasCommunityName ? "/create/core-values" : "/create/informational", - ); - }, [router, state.title, templateReviewSlug, updateState]); - - /** - * "Use without changes" from a template-review page. Drops users into the - * review-and-complete stage (`confirm-stakeholders` → `final-review`) so the - * publish flow — and its server-enforced sign-in gate (`publishRule` 401 → - * `openLogin`) — is reused. The template body becomes the rule document; - * the user's community name remains the rule title. - * - * Community-name branch: apply template body/summary immediately and jump - * to `confirm-stakeholders`. - * - * No-community-name branch: same template body/summary apply so state is - * ready, plus a `pendingTemplateAction` pin so `/create/review` later - * redirects past itself straight to `confirm-stakeholders` once community - * data is captured (see `CommunityReviewScreen`). Users aren't forced back - * through the template picker just to pick the same template again. - * - * Direct entry (marketing home "Popular templates" or `/templates` - * landed on directly) wipes the anonymous draft at the click site via - * `clearCreateFlowPersistedDrafts` before navigating, so `state.title` - * is empty and the no-community branch fires naturally. - */ - const handleUseTemplateWithoutChanges = useCallback(async () => { - if (!templateReviewSlug) return; - setTemplateReviewApplyError(null); - - setIsApplyingTemplate(true); - const loaded = await loadTemplateReviewBySlug(templateReviewSlug); - setIsApplyingTemplate(false); - if (loaded.ok === false) { - setTemplateReviewApplyError(loaded.message); - return; - } - const { template } = loaded; - const doc = template.body; - if (!doc || typeof doc !== "object" || Array.isArray(doc)) { - setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); - return; - } - const sectionsRaw = (doc as { sections?: unknown }).sections; - const sections = Array.isArray(sectionsRaw) - ? (sectionsRaw as Record[]) - : []; - if (sections.length === 0) { - setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed); - return; - } - - // Using the template verbatim: scrub any prior customize picks so they - // don't bleed into `document.coreValues` at publish time. - resetCustomRuleSelections(); - - // Seed the core-values snapshot from the Values section so the - // final-review chip modal can edit them (it keys edits by chip id). - // The Values entries themselves are then dropped from `sections` to - // avoid publishing `document.coreValues` and `document.sections.Values` - // for the same data — matches the "Customize" path's data shape. - const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc); - const sectionsWithoutValues = - Object.keys(coreValuesPrefill).length > 0 - ? sections.filter((s) => { - const name = (s as { categoryName?: unknown }).categoryName; - if (typeof name !== "string") return true; - const key = name.toLowerCase().replace(/[^a-z]+/g, ""); - return key !== "values" && key !== "corevalues"; - }) - : sections; - - const summaryRaw = - typeof template.description === "string" - ? template.description.trim() - : ""; - const hasCommunityName = - typeof state.title === "string" && state.title.trim().length > 0; - updateState({ - ...coreValuesPrefill, - sections: sectionsWithoutValues, - ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), - ...(hasCommunityName - ? { pendingTemplateAction: undefined } - : { - pendingTemplateAction: { - slug: templateReviewSlug, - mode: "useWithoutChanges", - }, - }), - }); - router.push( - hasCommunityName - ? "/create/confirm-stakeholders" - : "/create/informational", - ); - }, [ + const { + isTemplateReviewRoute, + templateReviewSlug, + isApplyingTemplate, + templateReviewApplyError, + setTemplateReviewApplyError, + handleCustomize: handleCustomizeTemplate, + handleUseWithoutChanges: handleUseTemplateWithoutChanges, + } = useTemplateReviewActions({ + pathname, + state, + updateState, resetCustomRuleSelections, router, - state.title, - templateReviewSlug, - updateState, - ]); + }); const runAuthenticatedExit = useCreateFlowExit({ state, @@ -499,80 +306,90 @@ function CreateFlowLayoutContent({ ? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep) : undefined; - const hasTopOverlays = - Boolean(draftSaveBannerMessage) || - Boolean(publishBannerMessage) || - Boolean(templateReviewApplyError) || - Boolean(communitySaveMagicLinkError) || - Boolean(communitySaveMagicLinkSuccess); + /** + * Top banner stack rendered above the main column when any of the + * shell-level statuses are active. Each entry maps to one ``; + * we filter out empty messages so the wrapper only mounts when at + * least one banner is actually showing. Order here is the visual + * stacking order (top → bottom). + */ + const topBanners: Array<{ + key: string; + status: "danger" | "positive"; + title: string; + description?: string; + onClose: () => void; + }> = [ + draftSaveBannerMessage + ? { + key: "draftSave", + status: "danger" as const, + title: messages.create.topNav.draftSaveBannerTitle, + description: draftSaveBannerMessage, + onClose: () => setDraftSaveBannerMessage(null), + } + : null, + publishBannerMessage + ? { + key: "publish", + status: "danger" as const, + title: + messages.create.reviewAndComplete.publish.finalizeBannerTitle, + description: publishBannerMessage, + onClose: () => setPublishBannerMessage(null), + } + : null, + templateReviewApplyError + ? { + key: "templateApply", + status: "danger" as const, + title: messages.create.templateReview.errors.applyFailed, + description: templateReviewApplyError, + onClose: () => setTemplateReviewApplyError(null), + } + : null, + communitySaveMagicLinkError + ? { + key: "magicLinkError", + status: "danger" as const, + title: communitySaveMessages.magicLinkErrorTitle, + description: communitySaveMagicLinkError, + onClose: () => setCommunitySaveMagicLinkError(null), + } + : null, + communitySaveMagicLinkSuccess + ? { + key: "magicLinkSuccess", + status: "positive" as const, + title: communitySaveMessages.magicLinkSuccessTitle, + description: communitySaveMessages.magicLinkSuccessDescription, + onClose: () => setCommunitySaveMagicLinkSuccess(false), + } + : null, + ].filter((b): b is NonNullable => b !== null); return (
- {hasTopOverlays ? ( + {topBanners.length > 0 ? (
- {draftSaveBannerMessage ? ( -
+ {topBanners.map((b) => ( +
setDraftSaveBannerMessage(null)} + status={b.status} + title={b.title} + description={b.description} + onClose={b.onClose} className="w-full" />
- ) : null} - {publishBannerMessage ? ( -
- setPublishBannerMessage(null)} - className="w-full" - /> -
- ) : null} - {templateReviewApplyError ? ( -
- setTemplateReviewApplyError(null)} - className="w-full" - /> -
- ) : null} - {communitySaveMagicLinkError ? ( -
- setCommunitySaveMagicLinkError(null)} - className="w-full" - /> -
- ) : null} - {communitySaveMagicLinkSuccess ? ( -
- setCommunitySaveMagicLinkSuccess(false)} - className="w-full" - /> -
- ) : null} + ))}
) : null} @@ -755,23 +572,21 @@ function CreateFlowLayoutContent({ > {currentStep === "final-review" ? isPublishing - ? messages.create.reviewAndComplete.publish.finalizeButtonPublishing + ? messages.create.reviewAndComplete.publish + .finalizeButtonPublishing : footer.finalizeCommunityRule - : currentStep === "confirm-stakeholders" - ? footer.confirmStakeholders - : currentStep === "community-context" - ? footer.confirmDescription - : currentStep === "community-structure" - ? footer.confirmDetails - : currentStep === "community-size" - ? footer.confirmMembers - : footer.next} + : getDefaultFooterLabel(currentStep, footer)} ) : null } onBackClick={ isTemplateReviewRoute - ? () => router.push("/") + ? () => + router.push( + templateReviewFooterBackToCreateReview + ? "/create/review" + : "/", + ) : previousStep ? goToPreviousStep : undefined diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts new file mode 100644 index 0000000..603a008 --- /dev/null +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; +import { publishRule } from "../../../../lib/create/api"; +import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; +import messages from "../../../../messages/en/index"; +import type { CreateFlowState } from "../types"; + +type AppRouterLike = { push: (_href: string) => void }; + +type OpenLogin = (args: { + variant: "default" | "saveProgress"; + nextPath: string; + backdropVariant: "blurredYellow"; +}) => void; + +export type UseCreateFlowFinalizeResult = { + /** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */ + publishBannerMessage: string | null; + setPublishBannerMessage: (_message: string | null) => void; + /** True from the moment the publish request fires until the response resolves. */ + isPublishing: boolean; + /** + * Build a publish payload from the current `CreateFlowState`, post it to + * `publishRule`, and route to `/create/completed` on success. + * + * Failure modes: + * - Payload validation fails → surface the localized banner message. + * - 401 from the API → re-open the login modal targeting `/create/final-review?syncDraft=1` so the user can retry post-auth. + * - Any other failure → show either the trimmed server message or a generic localized fallback. + */ + finalize: () => Promise; +}; + +/** + * Encapsulates the Final Review → publish flow that previously lived inline + * in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag) + * co-located with the publish handler so the layout shell only has to wire + * the resulting message into its banner stack. + */ +export function useCreateFlowFinalize({ + state, + router, + openLogin, +}: { + state: CreateFlowState; + router: AppRouterLike; + openLogin: OpenLogin; +}): UseCreateFlowFinalizeResult { + const [publishBannerMessage, setPublishBannerMessage] = useState< + string | null + >(null); + const [isPublishing, setIsPublishing] = useState(false); + + const finalize = useCallback(async () => { + setPublishBannerMessage(null); + const payloadResult = buildPublishPayload(state); + if (payloadResult.ok === false) { + setPublishBannerMessage( + payloadResult.error === "missingCommunityName" + ? messages.create.reviewAndComplete.publish.missingCommunityName + : payloadResult.error, + ); + return; + } + const { title, summary, document: ruleDocument } = payloadResult; + setIsPublishing(true); + const publishResult = await publishRule({ + title, + summary, + document: ruleDocument, + }); + setIsPublishing(false); + if (publishResult.ok === true) { + writeLastPublishedRule({ + id: publishResult.id, + title, + summary: summary ?? null, + document: ruleDocument, + }); + router.push("/create/completed"); + return; + } + if (publishResult.status === 401) { + openLogin({ + variant: "default", + nextPath: "/create/final-review?syncDraft=1", + backdropVariant: "blurredYellow", + }); + return; + } + setPublishBannerMessage( + publishResult.error.trim() !== "" + ? publishResult.error + : messages.create.reviewAndComplete.publish.genericPublishFailed, + ); + }, [state, router, openLogin]); + + return { + publishBannerMessage, + setPublishBannerMessage, + isPublishing, + finalize, + }; +} diff --git a/app/(app)/create/hooks/useCreateFlowNavigation.ts b/app/(app)/create/hooks/useCreateFlowNavigation.ts index 848e723..d9d7ebb 100644 --- a/app/(app)/create/hooks/useCreateFlowNavigation.ts +++ b/app/(app)/create/hooks/useCreateFlowNavigation.ts @@ -1,13 +1,18 @@ "use client"; -import { usePathname, useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useLayoutEffect, useMemo } from "react"; +import { useCreateFlow } from "../context/CreateFlowContext"; import type { CreateFlowStep } from "../types"; import { type CreateFlowNavigationOptions, + buildTemplateReviewHref, getNextStep, getPreviousStep, parseCreateFlowScreenFromPathname, + resolveCreateFlowBackTarget, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, } from "../utils/flowSteps"; /** @@ -25,7 +30,15 @@ const blurActiveElement = (): void => { /** * Hook for Create Rule Flow navigation. * - * Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps). + * 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`. + * + * Template review footer Back uses {@link buildTemplateReviewHref}’s + * `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so + * users who came from `/create/review` return there instead of `/`. */ export function useCreateFlowNavigation( options?: CreateFlowNavigationOptions, @@ -38,15 +51,46 @@ export function useCreateFlowNavigation( canGoBack: () => boolean; nextStep: CreateFlowStep | null; previousStep: CreateFlowStep | null; + /** On `/create/review-template/…`, footer Back should go to `/create/review`. */ + templateReviewFooterBackToCreateReview: boolean; } { const pathname = usePathname(); + const searchParams = useSearchParams(); const router = useRouter(); + const { state, updateState } = useCreateFlow(); const validStep = parseCreateFlowScreenFromPathname(pathname ?? null); + useLayoutEffect(() => { + if (!pathname?.includes("/create/review-template/")) return; + if ( + searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !== + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE + ) { + return; + } + if (state.templateReviewEntryFromCreateFlow === true) return; + updateState({ templateReviewEntryFromCreateFlow: true }); + }, [ + pathname, + searchParams, + state.templateReviewEntryFromCreateFlow, + updateState, + ]); + const nextStep = getNextStep(validStep, options); const previousStep = getPreviousStep(validStep, options); + const backTarget = useMemo( + () => + resolveCreateFlowBackTarget( + validStep, + options, + state.templateReviewBackSlug, + ), + [validStep, options?.skipCommunitySave, state.templateReviewBackSlug], + ); + const goToNextStep = useCallback(() => { blurActiveElement(); if (nextStep) { @@ -56,10 +100,26 @@ export function useCreateFlowNavigation( const goToPreviousStep = useCallback(() => { blurActiveElement(); - if (previousStep) { - router.push(`/create/${previousStep}`); + if (!backTarget) return; + if (backTarget.kind === "templateReview") { + router.push( + buildTemplateReviewHref(backTarget.slug, { + fromCreateWizard: state.templateReviewEntryFromCreateFlow === true, + }), + ); + return; } - }, [router, previousStep]); + router.push(`/create/${backTarget.step}`); + }, [router, backTarget, state.templateReviewEntryFromCreateFlow]); + + const templateReviewFooterBackToCreateReview = useMemo( + () => + Boolean(state.templateReviewEntryFromCreateFlow) || + (pathname?.includes("/create/review-template/") && + searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) === + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE), + [state.templateReviewEntryFromCreateFlow, pathname, searchParams], + ); const goToStep = useCallback( (step: CreateFlowStep) => { @@ -70,7 +130,7 @@ export function useCreateFlowNavigation( ); const canGoNext = useCallback(() => nextStep !== null, [nextStep]); - const canGoBack = useCallback(() => previousStep !== null, [previousStep]); + const canGoBack = useCallback(() => backTarget != null, [backTarget]); return { currentStep: validStep, @@ -81,5 +141,6 @@ export function useCreateFlowNavigation( canGoBack, nextStep, previousStep, + templateReviewFooterBackToCreateReview, }; } diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts new file mode 100644 index 0000000..970f35c --- /dev/null +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { + buildCoreValuesPrefillFromTemplateBody, + buildTemplateCustomizePrefill, +} from "../../../../lib/create/applyTemplatePrefill"; +import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug"; +import messages from "../../../../messages/en/index"; +import type { CreateFlowState } from "../types"; + +type AppRouterLike = { push: (_href: string) => void }; +type UpdateState = (_patch: Partial) => void; + +export type UseTemplateReviewActionsResult = { + /** True iff the current pathname is a template-review route (locale/basePath tolerant). */ + isTemplateReviewRoute: boolean; + /** Decoded slug parsed out of the template-review pathname, or null. */ + templateReviewSlug: string | null; + /** True between the fetch start and resolution for either action. */ + isApplyingTemplate: boolean; + /** Set when the template fetch failed or the body was malformed. Cleared at the start of each action. */ + templateReviewApplyError: string | null; + setTemplateReviewApplyError: (_message: string | null) => void; + /** + * Customize: apply the template's selections onto state and route to + * `/create/core-values` (if community name is set) or `/create/informational` + * with a `pendingTemplateAction` pin so `/create/review` can later replace + * itself with `/create/core-values`. + */ + handleCustomize: () => Promise; + /** + * Use without changes: scrub any prior customize picks, seed the core-values + * snapshot from the template's Values section, drop that section 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). + */ + handleUseWithoutChanges: () => Promise; +}; + +/** + * Encapsulates the two template-review footer actions (Customize / Use + * without changes) plus the small amount of state they share (in-flight + * flag, error banner, parsed slug). Called from `CreateFlowLayoutClient` + * once; extracting it here keeps the layout shell focused on rendering + * rather than orchestrating template fetch + state seeding. + * + * @example + * const { + * isTemplateReviewRoute, + * templateReviewSlug, + * isApplyingTemplate, + * templateReviewApplyError, + * setTemplateReviewApplyError, + * handleCustomize, + * handleUseWithoutChanges, + * } = useTemplateReviewActions({ pathname, state, updateState, resetCustomRuleSelections, router }); + */ +export function useTemplateReviewActions({ + pathname, + state, + updateState, + resetCustomRuleSelections, + router, +}: { + pathname: string | null | undefined; + state: CreateFlowState; + updateState: UpdateState; + resetCustomRuleSelections: () => void; + router: AppRouterLike; +}): UseTemplateReviewActionsResult { + const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); + const [templateReviewApplyError, setTemplateReviewApplyError] = useState< + string | null + >(null); + + const templateReviewSlug = useMemo(() => { + const m = pathname?.match(/\/create\/review-template\/([^/?#]+)/); + return m?.[1] ? decodeURIComponent(m[1]) : null; + }, [pathname]); + + const isTemplateReviewRoute = Boolean( + pathname?.includes("/create/review-template/"), + ); + + const handleCustomize = useCallback(async () => { + if (!templateReviewSlug) return; + setTemplateReviewApplyError(null); + setIsApplyingTemplate(true); + const loaded = await loadTemplateReviewBySlug(templateReviewSlug); + setIsApplyingTemplate(false); + if (loaded.ok === false) { + setTemplateReviewApplyError(loaded.message); + return; + } + const prefill = buildTemplateCustomizePrefill(loaded.template.body); + const hasCommunityName = + typeof state.title === "string" && state.title.trim().length > 0; + updateState({ + ...prefill, + templateReviewBackSlug: undefined, + ...(hasCommunityName + ? { pendingTemplateAction: undefined } + : { + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "customize", + }, + }), + }); + router.push( + hasCommunityName ? "/create/core-values" : "/create/informational", + ); + }, [router, state.title, templateReviewSlug, updateState]); + + const handleUseWithoutChanges = useCallback(async () => { + if (!templateReviewSlug) return; + setTemplateReviewApplyError(null); + setIsApplyingTemplate(true); + const loaded = await loadTemplateReviewBySlug(templateReviewSlug); + setIsApplyingTemplate(false); + if (loaded.ok === false) { + setTemplateReviewApplyError(loaded.message); + return; + } + const { template } = loaded; + const doc = template.body; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + setTemplateReviewApplyError( + messages.create.templateReview.errors.applyFailed, + ); + return; + } + const sectionsRaw = (doc as { sections?: unknown }).sections; + const sections = Array.isArray(sectionsRaw) + ? (sectionsRaw as Record[]) + : []; + if (sections.length === 0) { + setTemplateReviewApplyError( + messages.create.templateReview.errors.applyFailed, + ); + return; + } + + // Using the template verbatim: scrub any prior customize picks so they + // don't bleed into `document.coreValues` at publish time. + resetCustomRuleSelections(); + + // Seed the core-values snapshot from the Values section so the + // final-review chip modal can edit them (it keys edits by chip id). + // The Values entries themselves are then dropped from `sections` to + // avoid publishing `document.coreValues` and `document.sections.Values` + // for the same data — matches the "Customize" path's data shape. + const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc); + const sectionsWithoutValues = + Object.keys(coreValuesPrefill).length > 0 + ? sections.filter((s) => { + const name = (s as { categoryName?: unknown }).categoryName; + if (typeof name !== "string") return true; + const key = name.toLowerCase().replace(/[^a-z]+/g, ""); + return key !== "values" && key !== "corevalues"; + }) + : sections; + + const summaryRaw = + typeof template.description === "string" + ? template.description.trim() + : ""; + const hasCommunityName = + typeof state.title === "string" && state.title.trim().length > 0; + updateState({ + ...coreValuesPrefill, + sections: sectionsWithoutValues, + ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), + templateReviewBackSlug: templateReviewSlug, + ...(hasCommunityName + ? { pendingTemplateAction: undefined } + : { + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "useWithoutChanges", + }, + }), + }); + router.push( + hasCommunityName + ? "/create/confirm-stakeholders" + : "/create/informational", + ); + }, [ + resetCustomRuleSelections, + router, + state.title, + templateReviewSlug, + updateState, + ]); + + return { + isTemplateReviewRoute, + templateReviewSlug, + isApplyingTemplate, + templateReviewApplyError, + setTemplateReviewApplyError, + handleCustomize, + handleUseWithoutChanges, + }; +} diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 8de973a..ac585d2 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -15,6 +15,7 @@ import { buildFinalReviewCategoryRowsDetailed, type FinalReviewCategoryRowDetailed, } from "../../../../../lib/create/buildFinalReviewCategories"; +import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch"; import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping"; import { FinalReviewChipEditModal, @@ -93,53 +94,7 @@ export function FinalReviewScreen() { const handleSave = useCallback( (patch: FinalReviewChipEditPatch) => { markCreateFlowInteraction(); - switch (patch.groupKey) { - case "coreValues": { - updateState({ - coreValueDetailsByChipId: { - ...(state.coreValueDetailsByChipId ?? {}), - [patch.overrideKey]: patch.value, - }, - }); - return; - } - case "communication": { - updateState({ - communicationMethodDetailsById: { - ...(state.communicationMethodDetailsById ?? {}), - [patch.overrideKey]: patch.value, - }, - }); - return; - } - case "membership": { - updateState({ - membershipMethodDetailsById: { - ...(state.membershipMethodDetailsById ?? {}), - [patch.overrideKey]: patch.value, - }, - }); - return; - } - case "decisionApproaches": { - updateState({ - decisionApproachDetailsById: { - ...(state.decisionApproachDetailsById ?? {}), - [patch.overrideKey]: patch.value, - }, - }); - return; - } - case "conflictManagement": { - updateState({ - conflictManagementDetailsById: { - ...(state.conflictManagementDetailsById ?? {}), - [patch.overrideKey]: patch.value, - }, - }); - return; - } - } + updateState(applyFinalReviewChipEditPatch(state, patch)); }, [markCreateFlowInteraction, updateState, state], ); diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 0f44c25..f65ed77 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -156,6 +156,23 @@ export interface CreateFlowState { slug: string; mode: "customize" | "useWithoutChanges"; }; + /** + * 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. + */ + templateReviewBackSlug?: string; + /** + * True when the user opened `/create/review-template/{slug}` from the create + * wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back + * from template review targets `/create/review` and so returning from + * `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL. + */ + templateReviewEntryFromCreateFlow?: boolean; currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; diff --git a/app/(app)/create/utils/createFlowFooterLabels.ts b/app/(app)/create/utils/createFlowFooterLabels.ts new file mode 100644 index 0000000..be3a82a --- /dev/null +++ b/app/(app)/create/utils/createFlowFooterLabels.ts @@ -0,0 +1,38 @@ +import type footerMessages from "../../../../messages/en/create/footer.json"; +import type { CreateFlowStep } from "../types"; + +type FooterMessages = typeof footerMessages; + +/** + * Per-step label override for the default "next-step" primary footer + * button (the catch-all branch in `CreateFlowLayoutClient`'s footer that + * fires `goToNextStep` for steps without a bespoke footer). Steps absent + * from this map fall back to `footer.next`. + * + * `final-review` is handled separately by the caller because its label + * also depends on the in-flight publish flag (`finalizeButtonPublishing` + * vs `finalizeCommunityRule`). + */ +const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap< + CreateFlowStep, + keyof FooterMessages +> = new Map([ + ["confirm-stakeholders", "confirmStakeholders"], + ["community-context", "confirmDescription"], + ["community-structure", "confirmDetails"], + ["community-size", "confirmMembers"], +]); + +/** + * Resolve the localized label for the default "next-step" footer button. + * Returns the per-step override when one is registered, otherwise + * `footer.next`. Caller still owns the `final-review` special case. + */ +export function getDefaultFooterLabel( + step: CreateFlowStep | null | undefined, + footer: FooterMessages, +): string { + if (step == null) return footer.next; + const key = DEFAULT_FOOTER_LABEL_BY_STEP.get(step); + return key != null ? footer[key] : footer.next; +} diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index fad4baa..bbb57e6 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -79,6 +79,32 @@ export function getPreviousStep( return prev; } +/** + * 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). + */ +export type CreateFlowBackTarget = + | { kind: "step"; step: CreateFlowStep } + | { kind: "templateReview"; slug: string }; + +export function resolveCreateFlowBackTarget( + currentStep: CreateFlowStep | null | undefined, + options: CreateFlowNavigationOptions | undefined, + templateReviewBackSlug: string | undefined | null, +): CreateFlowBackTarget | null { + const slug = + typeof templateReviewBackSlug === "string" + ? templateReviewBackSlug.trim() + : ""; + if (currentStep === "confirm-stakeholders" && slug.length > 0) { + return { kind: "templateReview", slug }; + } + const prev = getPreviousStep(currentStep, options); + return prev != null ? { kind: "step", step: prev } : null; +} + /** * Returns the index of the step (0-based), or -1 if invalid */ @@ -118,3 +144,22 @@ export function parseCreateFlowScreenFromPathname( return isValidStep(segment) ? segment : null; } + +/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */ +export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const; +export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; + +/** + * `/create/review-template/{slug}` with optional marker so chrome can send + * footer Back to `/create/review` instead of marketing home. + */ +export function buildTemplateReviewHref( + slug: string, + options?: { fromCreateWizard?: boolean }, +): string { + const path = `/create/review-template/${encodeURIComponent(slug)}`; + if (options?.fromCreateWizard) { + return `${path}?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}`; + } + return path; +} diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx index 6446208..f3cbe19 100644 --- a/app/(marketing)/templates/TemplatesPageClient.tsx +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -6,6 +6,7 @@ import HeaderLockup from "../../components/type/HeaderLockup"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts"; +import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps"; import { useTranslation } from "../../contexts/MessagesContext"; export interface TemplatesPageClientProps { @@ -88,7 +89,9 @@ function TemplatesGrid({ // the user's community stage survives the detour through here. clearCreateFlowPersistedDrafts(); } - router.push(`/create/review-template/${encodeURIComponent(slug)}`); + router.push( + buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), + ); }} /> ); diff --git a/lib/create/applyFinalReviewChipEditPatch.ts b/lib/create/applyFinalReviewChipEditPatch.ts new file mode 100644 index 0000000..72b3aa9 --- /dev/null +++ b/lib/create/applyFinalReviewChipEditPatch.ts @@ -0,0 +1,59 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; +import type { FinalReviewChipEditPatch } from "../../app/(app)/create/components/FinalReviewChipEditModal"; + +/** + * Translate a {@link FinalReviewChipEditPatch} into the `Partial` + * patch that {@link CreateFlowState}'s update merger should write back. Each + * group key targets its own `*DetailsById` (or `coreValueDetailsByChipId`) + * record; the patch always merges the new value onto the existing record so + * other chips' overrides are preserved. + * + * The `switch` is exhaustive because {@link FinalReviewChipEditPatch} is a + * discriminated union — adding a new facet group in the modal forces a new + * `case` here at compile time, which is the whole reason this lives outside + * `FinalReviewScreen` (the screen used to host an identical 5-case switch). + * + * Exported as a pure function so it's unit-testable without React. + */ +export function applyFinalReviewChipEditPatch( + state: CreateFlowState, + patch: FinalReviewChipEditPatch, +): Partial { + switch (patch.groupKey) { + case "coreValues": + return { + coreValueDetailsByChipId: { + ...(state.coreValueDetailsByChipId ?? {}), + [patch.overrideKey]: patch.value, + }, + }; + case "communication": + return { + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }; + case "membership": + return { + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }; + case "decisionApproaches": + return { + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }; + case "conflictManagement": + return { + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [patch.overrideKey]: patch.value, + }, + }; + } +} diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 1ea208c..1876434 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -119,6 +119,8 @@ export const createFlowStateSchema = z }) .strict() .optional(), + templateReviewBackSlug: z.string().max(200).optional(), + templateReviewEntryFromCreateFlow: z.boolean().optional(), currentStep: createFlowStepSchema.optional(), sections: z.array(z.unknown()).optional(), stakeholders: z.array(z.unknown()).optional(), diff --git a/tests/pages/templates.test.jsx b/tests/pages/templates.test.jsx index b9b881c..61ada5d 100644 --- a/tests/pages/templates.test.jsx +++ b/tests/pages/templates.test.jsx @@ -113,10 +113,10 @@ describe("Templates page (/templates)", () => { ).toBe( JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }), ); - // No `?fromFlow=1` on the outbound review-template URL — the marker - // only disambiguates /templates' own click behavior. + // In-flow picks also pass `?fromFlow=1` on the template review URL so + // footer Back on `/create/review-template/…` returns to `/create/review`. expect(testRouter.push).toHaveBeenCalledWith( - "/create/review-template/consensus", + "/create/review-template/consensus?fromFlow=1", ); }); }); diff --git a/tests/unit/applyFinalReviewChipEditPatch.test.ts b/tests/unit/applyFinalReviewChipEditPatch.test.ts new file mode 100644 index 0000000..270061d --- /dev/null +++ b/tests/unit/applyFinalReviewChipEditPatch.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { applyFinalReviewChipEditPatch } from "../../lib/create/applyFinalReviewChipEditPatch"; +import type { CreateFlowState } from "../../app/(app)/create/types"; +import type { FinalReviewChipEditPatch } from "../../app/(app)/create/components/FinalReviewChipEditModal"; + +describe("applyFinalReviewChipEditPatch", () => { + it("creates the coreValueDetailsByChipId record when missing", () => { + const patch: FinalReviewChipEditPatch = { + groupKey: "coreValues", + overrideKey: "accessibility", + value: { meaning: "Be welcoming.", signals: "Captions on videos." }, + }; + + const result = applyFinalReviewChipEditPatch({}, patch); + + expect(result).toEqual({ + coreValueDetailsByChipId: { + accessibility: { + meaning: "Be welcoming.", + signals: "Captions on videos.", + }, + }, + }); + }); + + it("merges into the existing record without dropping siblings", () => { + const state: CreateFlowState = { + communicationMethodDetailsById: { + signal: { + corePrinciple: "Stay async-first.", + logisticsAdmin: "Daily check-ins.", + codeOfConduct: "Be kind.", + }, + }, + }; + const patch: FinalReviewChipEditPatch = { + groupKey: "communication", + overrideKey: "in-person-meetings", + value: { + corePrinciple: "Meet weekly.", + logisticsAdmin: "Hybrid format.", + codeOfConduct: "Listen actively.", + }, + }; + + const result = applyFinalReviewChipEditPatch(state, patch); + + expect(result.communicationMethodDetailsById).toEqual({ + signal: { + corePrinciple: "Stay async-first.", + logisticsAdmin: "Daily check-ins.", + codeOfConduct: "Be kind.", + }, + "in-person-meetings": { + corePrinciple: "Meet weekly.", + logisticsAdmin: "Hybrid format.", + codeOfConduct: "Listen actively.", + }, + }); + }); + + it("overwrites the same key when the user re-saves it", () => { + const state: CreateFlowState = { + membershipMethodDetailsById: { + "open-access": { + eligibility: "Anyone", + joiningProcess: "Sign up", + expectations: "Old expectations", + }, + }, + }; + const patch: FinalReviewChipEditPatch = { + groupKey: "membership", + overrideKey: "open-access", + value: { + eligibility: "Anyone over 18", + joiningProcess: "Sign up + intro call", + expectations: "New expectations", + }, + }; + + const result = applyFinalReviewChipEditPatch(state, patch); + + expect(result.membershipMethodDetailsById?.["open-access"]).toEqual({ + eligibility: "Anyone over 18", + joiningProcess: "Sign up + intro call", + expectations: "New expectations", + }); + }); + + it("routes decisionApproaches to its dedicated state field", () => { + const patch: FinalReviewChipEditPatch = { + groupKey: "decisionApproaches", + overrideKey: "lazy-consensus", + value: { + corePrinciple: "Silence implies assent.", + applicableScope: ["budget"], + selectedApplicableScope: ["budget"], + stepByStepInstructions: "Propose. Wait 72h.", + consensusLevel: 0.66, + objectionsDeadlocks: "Escalate to vote.", + }, + }; + + const result = applyFinalReviewChipEditPatch({}, patch); + + expect(Object.keys(result)).toEqual(["decisionApproachDetailsById"]); + }); + + it("routes conflictManagement to its dedicated state field", () => { + const patch: FinalReviewChipEditPatch = { + groupKey: "conflictManagement", + overrideKey: "peer-mediation", + value: { + corePrinciple: "Restore trust.", + applicableScope: ["interpersonal"], + selectedApplicableScope: ["interpersonal"], + processProtocol: "Pair the parties with a neutral facilitator.", + restorationFallbacks: "Council escalation.", + }, + }; + + const result = applyFinalReviewChipEditPatch({}, patch); + + expect(Object.keys(result)).toEqual(["conflictManagementDetailsById"]); + }); +}); diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index ec47882..4675d60 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -112,6 +112,20 @@ describe("createFlowStateSchema", () => { expect(r.success).toBe(true); }); + it("accepts templateReviewBackSlug", () => { + const r = createFlowStateSchema.safeParse({ + templateReviewBackSlug: "mutual-aid-mondays", + }); + expect(r.success).toBe(true); + }); + + it("accepts templateReviewEntryFromCreateFlow", () => { + const r = createFlowStateSchema.safeParse({ + templateReviewEntryFromCreateFlow: true, + }); + expect(r.success).toBe(true); + }); + it("rejects core value detail strings that are too long", () => { const r = createFlowStateSchema.safeParse({ coreValueDetailsByChipId: { diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index 36a02b7..bd10248 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from "vitest"; import { FLOW_STEP_ORDER, + buildTemplateReviewHref, getNextStep, getPreviousStep, isValidStep, getStepIndex, + resolveCreateFlowBackTarget, } from "../../app/(app)/create/utils/flowSteps"; describe("flowSteps", () => { @@ -72,4 +74,33 @@ describe("flowSteps", () => { expect(getNextStep("review", opts)).toBe("core-values"); expect(getPreviousStep("communication-methods", opts)).toBe("core-values"); }); + + it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => { + expect( + resolveCreateFlowBackTarget( + "confirm-stakeholders", + undefined, + "mutual-aid-mondays", + ), + ).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" }); + }); + + it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => { + expect( + resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined), + ).toEqual({ kind: "step", step: "conflict-management" }); + }); + + it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => { + expect( + resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "), + ).toEqual({ kind: "step", step: "conflict-management" }); + }); + + it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => { + expect(buildTemplateReviewHref("a/b")).toBe("/create/review-template/a%2Fb"); + expect(buildTemplateReviewHref("mutual-aid", { fromCreateWizard: true })).toBe( + "/create/review-template/mutual-aid?fromFlow=1", + ); + }); });