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