Template navigation and review/complete cleanup
This commit is contained in:
@@ -11,6 +11,8 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
|
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||||
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
|
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
|
||||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
||||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
@@ -20,11 +22,9 @@ import {
|
|||||||
} from "./utils/createFlowScreenRegistry";
|
} from "./utils/createFlowScreenRegistry";
|
||||||
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
|
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
|
||||||
import Button from "../../components/buttons/Button";
|
import Button from "../../components/buttons/Button";
|
||||||
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
|
|
||||||
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
publishRule,
|
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
} from "../../../lib/create/api";
|
} from "../../../lib/create/api";
|
||||||
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
||||||
@@ -33,12 +33,6 @@ import {
|
|||||||
setTransferPendingFlag,
|
setTransferPendingFlag,
|
||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { deleteServerDraft } from "../../../lib/create/api";
|
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 messages from "../../../messages/en/index";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||||
@@ -48,6 +42,7 @@ import {
|
|||||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
type CustomRuleConfirmFooterStep,
|
type CustomRuleConfirmFooterStep,
|
||||||
} from "./utils/customRuleConfirmFooterSteps";
|
} from "./utils/customRuleConfirmFooterSteps";
|
||||||
|
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||||
@@ -86,12 +81,14 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
|
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
|
||||||
<CreateFlowDraftSaveBannerProvider>
|
<CreateFlowDraftSaveBannerProvider>
|
||||||
<CreateFlowLayoutContent
|
<Suspense fallback={null}>
|
||||||
sessionUser={sessionUser}
|
<CreateFlowLayoutContent
|
||||||
sessionResolved={sessionResolved}
|
sessionUser={sessionUser}
|
||||||
>
|
sessionResolved={sessionResolved}
|
||||||
{children}
|
>
|
||||||
</CreateFlowLayoutContent>
|
{children}
|
||||||
|
</CreateFlowLayoutContent>
|
||||||
|
</Suspense>
|
||||||
</CreateFlowDraftSaveBannerProvider>
|
</CreateFlowDraftSaveBannerProvider>
|
||||||
</CreateFlowProvider>
|
</CreateFlowProvider>
|
||||||
);
|
);
|
||||||
@@ -120,6 +117,7 @@ function CreateFlowLayoutContent({
|
|||||||
previousStep,
|
previousStep,
|
||||||
goToNextStep,
|
goToNextStep,
|
||||||
goToPreviousStep,
|
goToPreviousStep,
|
||||||
|
templateReviewFooterBackToCreateReview,
|
||||||
} = useCreateFlowNavigation(
|
} = useCreateFlowNavigation(
|
||||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||||
);
|
);
|
||||||
@@ -127,14 +125,6 @@ function CreateFlowLayoutContent({
|
|||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||||
useCreateFlowDraftSaveBanner();
|
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] =
|
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
||||||
@@ -143,211 +133,28 @@ function CreateFlowLayoutContent({
|
|||||||
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const templateReviewMatch = pathname?.match(
|
const {
|
||||||
/\/create\/review-template\/([^/?#]+)/,
|
publishBannerMessage,
|
||||||
);
|
setPublishBannerMessage,
|
||||||
const templateReviewSlug = templateReviewMatch?.[1]
|
isPublishing,
|
||||||
? decodeURIComponent(templateReviewMatch[1])
|
finalize: handleFinalize,
|
||||||
: null;
|
} = useCreateFlowFinalize({ state, router, openLogin });
|
||||||
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
|
|
||||||
const isTemplateReviewRoute = Boolean(
|
|
||||||
pathname?.includes("/create/review-template/"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFinalize = useCallback(async () => {
|
const {
|
||||||
setPublishBannerMessage(null);
|
isTemplateReviewRoute,
|
||||||
const payloadResult = buildPublishPayload(state);
|
templateReviewSlug,
|
||||||
if (payloadResult.ok === false) {
|
isApplyingTemplate,
|
||||||
setPublishBannerMessage(
|
templateReviewApplyError,
|
||||||
payloadResult.error === "missingCommunityName"
|
setTemplateReviewApplyError,
|
||||||
? messages.create.reviewAndComplete.publish.missingCommunityName
|
handleCustomize: handleCustomizeTemplate,
|
||||||
: payloadResult.error,
|
handleUseWithoutChanges: handleUseTemplateWithoutChanges,
|
||||||
);
|
} = useTemplateReviewActions({
|
||||||
return;
|
pathname,
|
||||||
}
|
state,
|
||||||
const { title, summary, document: ruleDocument } = payloadResult;
|
updateState,
|
||||||
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<string, unknown>[])
|
|
||||||
: [];
|
|
||||||
if (sections.length === 0) {
|
|
||||||
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Using the template verbatim: scrub any prior customize picks so they
|
|
||||||
// don't bleed into `document.coreValues` at publish time.
|
|
||||||
resetCustomRuleSelections();
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
resetCustomRuleSelections,
|
resetCustomRuleSelections,
|
||||||
router,
|
router,
|
||||||
state.title,
|
});
|
||||||
templateReviewSlug,
|
|
||||||
updateState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const runAuthenticatedExit = useCreateFlowExit({
|
const runAuthenticatedExit = useCreateFlowExit({
|
||||||
state,
|
state,
|
||||||
@@ -499,80 +306,90 @@ function CreateFlowLayoutContent({
|
|||||||
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const hasTopOverlays =
|
/**
|
||||||
Boolean(draftSaveBannerMessage) ||
|
* Top banner stack rendered above the main column when any of the
|
||||||
Boolean(publishBannerMessage) ||
|
* shell-level statuses are active. Each entry maps to one `<Alert>`;
|
||||||
Boolean(templateReviewApplyError) ||
|
* we filter out empty messages so the wrapper only mounts when at
|
||||||
Boolean(communitySaveMagicLinkError) ||
|
* least one banner is actually showing. Order here is the visual
|
||||||
Boolean(communitySaveMagicLinkSuccess);
|
* 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<typeof b> => b !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||||
{hasTopOverlays ? (
|
{topBanners.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{draftSaveBannerMessage ? (
|
{topBanners.map((b) => (
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
<div
|
||||||
|
key={b.key}
|
||||||
|
className="pointer-events-auto mx-auto w-full max-w-[960px]"
|
||||||
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
type="banner"
|
||||||
status="danger"
|
status={b.status}
|
||||||
title={messages.create.topNav.draftSaveBannerTitle}
|
title={b.title}
|
||||||
description={draftSaveBannerMessage}
|
description={b.description}
|
||||||
onClose={() => setDraftSaveBannerMessage(null)}
|
onClose={b.onClose}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
))}
|
||||||
{publishBannerMessage ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={messages.create.reviewAndComplete.publish.finalizeBannerTitle}
|
|
||||||
description={publishBannerMessage}
|
|
||||||
onClose={() => setPublishBannerMessage(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{templateReviewApplyError ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={messages.create.templateReview.errors.applyFailed}
|
|
||||||
description={templateReviewApplyError}
|
|
||||||
onClose={() => setTemplateReviewApplyError(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{communitySaveMagicLinkError ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={communitySaveMessages.magicLinkErrorTitle}
|
|
||||||
description={communitySaveMagicLinkError}
|
|
||||||
onClose={() => setCommunitySaveMagicLinkError(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{communitySaveMagicLinkSuccess ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="positive"
|
|
||||||
title={communitySaveMessages.magicLinkSuccessTitle}
|
|
||||||
description={communitySaveMessages.magicLinkSuccessDescription}
|
|
||||||
onClose={() => setCommunitySaveMagicLinkSuccess(false)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -755,23 +572,21 @@ function CreateFlowLayoutContent({
|
|||||||
>
|
>
|
||||||
{currentStep === "final-review"
|
{currentStep === "final-review"
|
||||||
? isPublishing
|
? isPublishing
|
||||||
? messages.create.reviewAndComplete.publish.finalizeButtonPublishing
|
? messages.create.reviewAndComplete.publish
|
||||||
|
.finalizeButtonPublishing
|
||||||
: footer.finalizeCommunityRule
|
: footer.finalizeCommunityRule
|
||||||
: currentStep === "confirm-stakeholders"
|
: getDefaultFooterLabel(currentStep, footer)}
|
||||||
? footer.confirmStakeholders
|
|
||||||
: currentStep === "community-context"
|
|
||||||
? footer.confirmDescription
|
|
||||||
: currentStep === "community-structure"
|
|
||||||
? footer.confirmDetails
|
|
||||||
: currentStep === "community-size"
|
|
||||||
? footer.confirmMembers
|
|
||||||
: footer.next}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
onBackClick={
|
onBackClick={
|
||||||
isTemplateReviewRoute
|
isTemplateReviewRoute
|
||||||
? () => router.push("/")
|
? () =>
|
||||||
|
router.push(
|
||||||
|
templateReviewFooterBackToCreateReview
|
||||||
|
? "/create/review"
|
||||||
|
: "/",
|
||||||
|
)
|
||||||
: previousStep
|
: previousStep
|
||||||
? goToPreviousStep
|
? goToPreviousStep
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useLayoutEffect, useMemo } from "react";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
import {
|
import {
|
||||||
type CreateFlowNavigationOptions,
|
type CreateFlowNavigationOptions,
|
||||||
|
buildTemplateReviewHref,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getPreviousStep,
|
getPreviousStep,
|
||||||
parseCreateFlowScreenFromPathname,
|
parseCreateFlowScreenFromPathname,
|
||||||
|
resolveCreateFlowBackTarget,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
} from "../utils/flowSteps";
|
} from "../utils/flowSteps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +30,15 @@ const blurActiveElement = (): void => {
|
|||||||
/**
|
/**
|
||||||
* Hook for Create Rule Flow navigation.
|
* 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(
|
export function useCreateFlowNavigation(
|
||||||
options?: CreateFlowNavigationOptions,
|
options?: CreateFlowNavigationOptions,
|
||||||
@@ -38,15 +51,46 @@ export function useCreateFlowNavigation(
|
|||||||
canGoBack: () => boolean;
|
canGoBack: () => boolean;
|
||||||
nextStep: CreateFlowStep | null;
|
nextStep: CreateFlowStep | null;
|
||||||
previousStep: CreateFlowStep | null;
|
previousStep: CreateFlowStep | null;
|
||||||
|
/** On `/create/review-template/…`, footer Back should go to `/create/review`. */
|
||||||
|
templateReviewFooterBackToCreateReview: boolean;
|
||||||
} {
|
} {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { state, updateState } = useCreateFlow();
|
||||||
|
|
||||||
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
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 nextStep = getNextStep(validStep, options);
|
||||||
const previousStep = getPreviousStep(validStep, options);
|
const previousStep = getPreviousStep(validStep, options);
|
||||||
|
|
||||||
|
const backTarget = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveCreateFlowBackTarget(
|
||||||
|
validStep,
|
||||||
|
options,
|
||||||
|
state.templateReviewBackSlug,
|
||||||
|
),
|
||||||
|
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
|
||||||
|
);
|
||||||
|
|
||||||
const goToNextStep = useCallback(() => {
|
const goToNextStep = useCallback(() => {
|
||||||
blurActiveElement();
|
blurActiveElement();
|
||||||
if (nextStep) {
|
if (nextStep) {
|
||||||
@@ -56,10 +100,26 @@ export function useCreateFlowNavigation(
|
|||||||
|
|
||||||
const goToPreviousStep = useCallback(() => {
|
const goToPreviousStep = useCallback(() => {
|
||||||
blurActiveElement();
|
blurActiveElement();
|
||||||
if (previousStep) {
|
if (!backTarget) return;
|
||||||
router.push(`/create/${previousStep}`);
|
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(
|
const goToStep = useCallback(
|
||||||
(step: CreateFlowStep) => {
|
(step: CreateFlowStep) => {
|
||||||
@@ -70,7 +130,7 @@ export function useCreateFlowNavigation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||||
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
|
const canGoBack = useCallback(() => backTarget != null, [backTarget]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentStep: validStep,
|
currentStep: validStep,
|
||||||
@@ -81,5 +141,6 @@ export function useCreateFlowNavigation(
|
|||||||
canGoBack,
|
canGoBack,
|
||||||
nextStep,
|
nextStep,
|
||||||
previousStep,
|
previousStep,
|
||||||
|
templateReviewFooterBackToCreateReview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CreateFlowState>) => 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<void>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
if (sections.length === 0) {
|
||||||
|
setTemplateReviewApplyError(
|
||||||
|
messages.create.templateReview.errors.applyFailed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using the template verbatim: scrub any prior customize picks so they
|
||||||
|
// don't bleed into `document.coreValues` at publish time.
|
||||||
|
resetCustomRuleSelections();
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
buildFinalReviewCategoryRowsDetailed,
|
buildFinalReviewCategoryRowsDetailed,
|
||||||
type FinalReviewCategoryRowDetailed,
|
type FinalReviewCategoryRowDetailed,
|
||||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||||
|
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
||||||
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
||||||
import {
|
import {
|
||||||
FinalReviewChipEditModal,
|
FinalReviewChipEditModal,
|
||||||
@@ -93,53 +94,7 @@ export function FinalReviewScreen() {
|
|||||||
const handleSave = useCallback(
|
const handleSave = useCallback(
|
||||||
(patch: FinalReviewChipEditPatch) => {
|
(patch: FinalReviewChipEditPatch) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
switch (patch.groupKey) {
|
updateState(applyFinalReviewChipEditPatch(state, patch));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction, updateState, state],
|
[markCreateFlowInteraction, updateState, state],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ export interface CreateFlowState {
|
|||||||
slug: string;
|
slug: string;
|
||||||
mode: "customize" | "useWithoutChanges";
|
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;
|
currentStep?: CreateFlowStep;
|
||||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||||
sections?: Record<string, unknown>[];
|
sections?: Record<string, unknown>[];
|
||||||
|
|||||||
@@ -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<CreateFlowStep, keyof FooterMessages>([
|
||||||
|
["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;
|
||||||
|
}
|
||||||
@@ -79,6 +79,32 @@ export function getPreviousStep(
|
|||||||
return prev;
|
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
|
* Returns the index of the step (0-based), or -1 if invalid
|
||||||
*/
|
*/
|
||||||
@@ -118,3 +144,22 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
|
|
||||||
return isValidStep(segment) ? segment : null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
|||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
||||||
|
import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps";
|
||||||
import { useTranslation } from "../../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
|
||||||
export interface TemplatesPageClientProps {
|
export interface TemplatesPageClientProps {
|
||||||
@@ -88,7 +89,9 @@ function TemplatesGrid({
|
|||||||
// the user's community stage survives the detour through here.
|
// the user's community stage survives the detour through here.
|
||||||
clearCreateFlowPersistedDrafts();
|
clearCreateFlowPersistedDrafts();
|
||||||
}
|
}
|
||||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
router.push(
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<CreateFlowState>`
|
||||||
|
* 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<CreateFlowState> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,8 @@ export const createFlowStateSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
templateReviewBackSlug: z.string().max(200).optional(),
|
||||||
|
templateReviewEntryFromCreateFlow: z.boolean().optional(),
|
||||||
currentStep: createFlowStepSchema.optional(),
|
currentStep: createFlowStepSchema.optional(),
|
||||||
sections: z.array(z.unknown()).optional(),
|
sections: z.array(z.unknown()).optional(),
|
||||||
stakeholders: z.array(z.unknown()).optional(),
|
stakeholders: z.array(z.unknown()).optional(),
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ describe("Templates page (/templates)", () => {
|
|||||||
).toBe(
|
).toBe(
|
||||||
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
||||||
);
|
);
|
||||||
// No `?fromFlow=1` on the outbound review-template URL — the marker
|
// In-flow picks also pass `?fromFlow=1` on the template review URL so
|
||||||
// only disambiguates /templates' own click behavior.
|
// footer Back on `/create/review-template/…` returns to `/create/review`.
|
||||||
expect(testRouter.push).toHaveBeenCalledWith(
|
expect(testRouter.push).toHaveBeenCalledWith(
|
||||||
"/create/review-template/consensus",
|
"/create/review-template/consensus?fromFlow=1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,20 @@ describe("createFlowStateSchema", () => {
|
|||||||
expect(r.success).toBe(true);
|
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", () => {
|
it("rejects core value detail strings that are too long", () => {
|
||||||
const r = createFlowStateSchema.safeParse({
|
const r = createFlowStateSchema.safeParse({
|
||||||
coreValueDetailsByChipId: {
|
coreValueDetailsByChipId: {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
FLOW_STEP_ORDER,
|
FLOW_STEP_ORDER,
|
||||||
|
buildTemplateReviewHref,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getPreviousStep,
|
getPreviousStep,
|
||||||
isValidStep,
|
isValidStep,
|
||||||
getStepIndex,
|
getStepIndex,
|
||||||
|
resolveCreateFlowBackTarget,
|
||||||
} from "../../app/(app)/create/utils/flowSteps";
|
} from "../../app/(app)/create/utils/flowSteps";
|
||||||
|
|
||||||
describe("flowSteps", () => {
|
describe("flowSteps", () => {
|
||||||
@@ -72,4 +74,33 @@ describe("flowSteps", () => {
|
|||||||
expect(getNextStep("review", opts)).toBe("core-values");
|
expect(getNextStep("review", opts)).toBe("core-values");
|
||||||
expect(getPreviousStep("communication-methods", 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user