Create flow centralization and cleanup
This commit is contained in:
@@ -16,6 +16,9 @@ alwaysApply: false
|
||||
`app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
|
||||
inside a screen — pick the matching shell (`CreateFlowStepShell` /
|
||||
`CreateFlowTwoColumnSelectShell`).
|
||||
- Keep create-flow step routing centralized in
|
||||
`app/(app)/create/utils/createFlowPaths.ts` (`createFlowStepPath`,
|
||||
`CREATE_ROUTES`) — do not introduce new hardcoded `/create/...` literals.
|
||||
- Shared create-flow pieces go in `app/(app)/create/components/` (layout shells,
|
||||
field composites). Generic primitives go in `app/components/`.
|
||||
|
||||
@@ -49,8 +52,9 @@ file are a smell once they're used more than once.
|
||||
namespace (see `localization.mdc`).
|
||||
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
|
||||
constants — expect replacement with live data.
|
||||
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
|
||||
constants — expect replacement with live data.
|
||||
- Custom-rule facet mappings (step ids, template-category aliases, selection
|
||||
keys, strip keys) must be sourced from `lib/create/customRuleFacets.ts`
|
||||
(`CUSTOM_RULE_FACETS`) instead of adding new ad-hoc switches/tables.
|
||||
|
||||
## Interaction tracking
|
||||
|
||||
|
||||
@@ -20,12 +20,20 @@ import {
|
||||
getNextStep,
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
createFlowStepUsesSelectSplitScroll,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "./utils/flowSteps";
|
||||
import {
|
||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||
CREATE_ROUTES,
|
||||
createFlowStepPath,
|
||||
createFlowStepPathAfterStrippingReviewReturn,
|
||||
createFlowStepPathWithSyncDraft,
|
||||
} from "./utils/createFlowPaths";
|
||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||
import {
|
||||
createFlowStepUsesCenteredTextLayout,
|
||||
@@ -42,12 +50,12 @@ import {
|
||||
clearAnonymousCreateFlowStorage,
|
||||
setTransferPendingFlag,
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import type { CreateFlowMethodCardFacetSection } from "./types";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleSelectionMissing,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import { deleteServerDraft } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
@@ -193,8 +201,8 @@ function CreateFlowLayoutContent({
|
||||
|
||||
const loginReturnPath =
|
||||
currentStep === "edit-rule"
|
||||
? "/create/edit-rule?syncDraft=1"
|
||||
: "/create/final-review?syncDraft=1";
|
||||
? createFlowStepPathWithSyncDraft("edit-rule")
|
||||
: createFlowStepPathWithSyncDraft("final-review");
|
||||
|
||||
const {
|
||||
publishBannerMessage,
|
||||
@@ -248,7 +256,7 @@ function CreateFlowLayoutContent({
|
||||
if (sessionUser) {
|
||||
void deleteServerDraft();
|
||||
}
|
||||
router.push("/");
|
||||
router.push(CREATE_ROUTES.root);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,7 +270,7 @@ function CreateFlowLayoutContent({
|
||||
variant: "saveProgress",
|
||||
nextPath:
|
||||
returnToTemplateReview ??
|
||||
`${pathname ?? "/create"}?syncDraft=1`,
|
||||
`${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
@@ -278,7 +286,7 @@ function CreateFlowLayoutContent({
|
||||
sessionUser &&
|
||||
currentStep === "community-save"
|
||||
) {
|
||||
router.replace("/create/review");
|
||||
router.replace(CREATE_ROUTES.review);
|
||||
}
|
||||
}, [sessionResolved, sessionUser, currentStep, router]);
|
||||
|
||||
@@ -294,12 +302,12 @@ function CreateFlowLayoutContent({
|
||||
if (currentStep !== "edit-rule") return;
|
||||
const last = readLastPublishedRule();
|
||||
if (!last) {
|
||||
router.replace("/create/completed");
|
||||
router.replace(CREATE_ROUTES.completed);
|
||||
return;
|
||||
}
|
||||
const editingId = state.editingPublishedRuleId?.trim() ?? "";
|
||||
if (editingId.length > 0 && editingId !== last.id) {
|
||||
router.replace("/create/completed");
|
||||
router.replace(CREATE_ROUTES.completed);
|
||||
return;
|
||||
}
|
||||
const titleOk =
|
||||
@@ -307,9 +315,7 @@ function CreateFlowLayoutContent({
|
||||
const sectionsClear = (state.sections?.length ?? 0) === 0;
|
||||
const patch = createFlowStateFromPublishedRule(last);
|
||||
const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch);
|
||||
const METHOD_CARD_PIN_FACETS: readonly CreateFlowMethodCardFacetSection[] =
|
||||
["communication", "membership", "decisionApproaches", "conflictManagement"];
|
||||
const needsPinMerge = METHOD_CARD_PIN_FACETS.some(
|
||||
const needsPinMerge = METHOD_FACET_API_SECTION_IDS.some(
|
||||
(key) =>
|
||||
pinPatch[key] === true &&
|
||||
state.methodSectionsPinCommitted?.[key] !== true,
|
||||
@@ -406,11 +412,9 @@ function CreateFlowLayoutContent({
|
||||
currentStep === "final-review" || currentStep === "edit-rule";
|
||||
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||
const isSelectSplitScrollStep =
|
||||
currentStep === "community-size" ||
|
||||
currentStep === "community-structure" ||
|
||||
currentStep === "core-values" ||
|
||||
currentStep === "decision-approaches";
|
||||
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||
currentStep,
|
||||
);
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
|
||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||
@@ -586,7 +590,7 @@ function CreateFlowLayoutContent({
|
||||
editingPublishedRuleId: last.id,
|
||||
sections: [],
|
||||
});
|
||||
router.push("/create/edit-rule");
|
||||
router.push(createFlowStepPath("edit-rule"));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -738,15 +742,11 @@ function CreateFlowLayoutContent({
|
||||
setMethodSectionsPinCommitted(facet, true);
|
||||
}
|
||||
if (reviewReturnTarget) {
|
||||
const params = new URLSearchParams(
|
||||
searchParams?.toString() ?? "",
|
||||
);
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
const qs = params.toString();
|
||||
router.push(
|
||||
qs.length > 0
|
||||
? `/create/${reviewReturnTarget}?${qs}`
|
||||
: `/create/${reviewReturnTarget}`,
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
reviewReturnTarget,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -784,20 +784,16 @@ function CreateFlowLayoutContent({
|
||||
? () =>
|
||||
router.push(
|
||||
templateReviewFooterBackToCreateReview
|
||||
? "/create/review"
|
||||
: "/",
|
||||
? CREATE_ROUTES.review
|
||||
: CREATE_ROUTES.root,
|
||||
)
|
||||
: reviewReturnTarget
|
||||
? () => {
|
||||
const params = new URLSearchParams(
|
||||
searchParams?.toString() ?? "",
|
||||
);
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
const qs = params.toString();
|
||||
router.push(
|
||||
qs.length > 0
|
||||
? `/create/${reviewReturnTarget}?${qs}`
|
||||
: `/create/${reviewReturnTarget}`,
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
reviewReturnTarget,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
: previousStep
|
||||
|
||||
@@ -185,8 +185,8 @@ export function CreateFlowProvider({
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
}, []);
|
||||
|
||||
// Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`.
|
||||
// Kept in sync with `CreateFlowState` comments marked "Create Custom —".
|
||||
// Keys cleared here match `STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS` from
|
||||
// `lib/create/customRuleFacets.ts` (CUSTOM_RULE_FACETS / CR-92).
|
||||
const resetCustomRuleSelections = useCallback(() => {
|
||||
setState((prev) => stripCustomRuleSelectionFields(prev));
|
||||
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../utils/flowSteps";
|
||||
import { createFlowStepPath } from "../utils/createFlowPaths";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
|
||||
@@ -80,7 +81,7 @@ export function useCreateFlowFinalize({
|
||||
document: ruleDocument,
|
||||
});
|
||||
updateState({ editingPublishedRuleId: undefined });
|
||||
router.push("/create/completed");
|
||||
router.push(createFlowStepPath("completed"));
|
||||
return;
|
||||
}
|
||||
if (updateResult.status === 401) {
|
||||
@@ -113,7 +114,10 @@ export function useCreateFlowFinalize({
|
||||
document: ruleDocument,
|
||||
});
|
||||
router.push(
|
||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||
createFlowStepPath("completed", {
|
||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
|
||||
* Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92).
|
||||
*/
|
||||
export type RecommendationSection =
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
export type RecommendationSection = MethodFacetApiSectionId;
|
||||
|
||||
export type FacetRecommendationsResult = {
|
||||
/** `true` once the network call completes (or short-circuits with no facets). */
|
||||
|
||||
@@ -2,20 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
import { renderCreateFlowScreen } from "./createFlowScreenComponents";
|
||||
|
||||
/**
|
||||
* Maps each wizard `screenId` to its screen component.
|
||||
@@ -23,73 +10,14 @@ import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
|
||||
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
|
||||
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
|
||||
*
|
||||
* Implementation lives in {@link renderCreateFlowScreen} (`createFlowScreenComponents.tsx`)
|
||||
* so the registry metadata and this router stay easier to keep in sync (CR-92 §3).
|
||||
*/
|
||||
export function CreateFlowScreenView({
|
||||
screenId,
|
||||
}: {
|
||||
screenId: CreateFlowStep;
|
||||
}): ReactNode {
|
||||
switch (screenId) {
|
||||
case "informational":
|
||||
return <InformationalScreen />;
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
return <CommunityStructureSelectScreen />;
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={200}
|
||||
mainAlign="center"
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
return <CommunitySizeSelectScreen />;
|
||||
case "community-upload":
|
||||
return <CommunityUploadScreen />;
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
return <FinalReviewScreen />;
|
||||
case "edit-rule":
|
||||
return <FinalReviewScreen variant="editPublished" />;
|
||||
case "completed":
|
||||
return <CompletedScreen />;
|
||||
default: {
|
||||
const _exhaustive: never = screenId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
return renderCreateFlowScreen(screenId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
|
||||
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
|
||||
* new steps do not drift.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||
switch (screenId) {
|
||||
case "informational":
|
||||
return <InformationalScreen />;
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
return <CommunityStructureSelectScreen />;
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={200}
|
||||
mainAlign="center"
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
return <CommunitySizeSelectScreen />;
|
||||
case "community-upload":
|
||||
return <CommunityUploadScreen />;
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
return <FinalReviewScreen />;
|
||||
case "edit-rule":
|
||||
return <FinalReviewScreen variant="editPublished" />;
|
||||
case "completed":
|
||||
return <CompletedScreen />;
|
||||
default: {
|
||||
const _exhaustive: never = screenId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,7 @@ import {
|
||||
vectorMarkPath,
|
||||
} from "../../../../../lib/assetUtils";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
|
||||
/**
|
||||
* 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",
|
||||
};
|
||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
@@ -56,8 +44,10 @@ export function CommunityReviewScreen() {
|
||||
if (firedRedirectRef.current) return;
|
||||
const pending = state.pendingTemplateAction;
|
||||
if (!pending) return;
|
||||
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
|
||||
if (!target) return;
|
||||
const target =
|
||||
pending.mode === "customize"
|
||||
? createFlowStepPath("core-values")
|
||||
: createFlowStepPath("confirm-stakeholders");
|
||||
firedRedirectRef.current = true;
|
||||
const pinMerge =
|
||||
pending.mode === "customize"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* including step types, state management, and context interfaces.
|
||||
*/
|
||||
|
||||
import type { MethodFacetApiSectionId } from "../../../lib/create/customRuleFacets";
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create rule flow (URL segment after `/create/`).
|
||||
* Create Community order matches Figma; `review` closes that stage per design.
|
||||
@@ -36,12 +38,11 @@ export type CreateFlowTextStateField =
|
||||
| "communityContext"
|
||||
| "communitySaveEmail";
|
||||
|
||||
/** Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). */
|
||||
export type CreateFlowMethodCardFacetSection =
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
/**
|
||||
* Facet-backed method card stacks (`GET /api/create-flow/methods?section=`).
|
||||
* Canonical ids: {@link METHOD_FACET_API_SECTION_IDS} in `lib/create/customRuleFacets.ts`.
|
||||
*/
|
||||
export type CreateFlowMethodCardFacetSection = MethodFacetApiSectionId;
|
||||
|
||||
/**
|
||||
* Serialized chip row for `community-structure` (preset + custom labels).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Central `/create/...` path builders (Linear CR-92 §2).
|
||||
* Prefer these over string literals so layout, redirects, hooks, and tests stay aligned.
|
||||
*/
|
||||
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps";
|
||||
|
||||
export const CREATE_ROUTES = {
|
||||
root: "/",
|
||||
createRoot: "/create",
|
||||
/** First step resolves via redirect from `/create`. */
|
||||
createFirstStep: "/create",
|
||||
review: "/create/review",
|
||||
finalReview: "/create/final-review",
|
||||
completed: "/create/completed",
|
||||
editRule: "/create/edit-rule",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Post-login return and session-gate paths on wizard steps.
|
||||
* (Also used when `pathname` is unknown but `syncDraft` must be appended.)
|
||||
*/
|
||||
export const CREATE_FLOW_SYNC_DRAFT_QUERY = "syncDraft" as const;
|
||||
export const CREATE_FLOW_SYNC_DRAFT_VALUE = "1" as const;
|
||||
|
||||
export function createFlowStepPathWithSyncDraft(step: CreateFlowStep): string {
|
||||
return createFlowStepPath(step, {
|
||||
[CREATE_FLOW_SYNC_DRAFT_QUERY]: CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateFlowPathQuery = Record<
|
||||
string,
|
||||
string | number | boolean | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Path for a wizard step: `/create/{screenId}` with optional query string.
|
||||
*/
|
||||
export function createFlowStepPath(
|
||||
step: CreateFlowStep,
|
||||
query?: CreateFlowPathQuery,
|
||||
): string {
|
||||
const base = `/create/${step}`;
|
||||
if (query == null || Object.keys(query).length === 0) return base;
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined) continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const q = sp.toString();
|
||||
return q.length > 0 ? `${base}?${q}` : base;
|
||||
}
|
||||
|
||||
export function createCompletedPath(query?: CreateFlowPathQuery): string {
|
||||
return createFlowStepPath("completed", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back from a facet step to final-review / edit-rule, dropping
|
||||
* `reviewReturn` from the current query while preserving other params.
|
||||
*/
|
||||
export function createFlowStepPathAfterStrippingReviewReturn(
|
||||
step: CreateFlowStep,
|
||||
searchParams: URLSearchParams | null | undefined,
|
||||
): string {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
const query: CreateFlowPathQuery = {};
|
||||
params.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
return createFlowStepPath(
|
||||
step,
|
||||
Object.keys(query).length > 0 ? query : undefined,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CUSTOM_RULE_FACETS } from "../../../../lib/create/customRuleFacets";
|
||||
import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
@@ -11,9 +12,9 @@ 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.
|
||||
* footer message differ — rows are derived from {@link CUSTOM_RULE_FACETS}
|
||||
* (Linear CR-92) so `CreateFlowLayoutClient` stays aligned with template
|
||||
* prefill, strip keys, and API section ids.
|
||||
*
|
||||
* `selectionIds` returns the currently-selected ids array from flow
|
||||
* state for that step (empty array when nothing has been selected or
|
||||
@@ -39,33 +40,11 @@ export type CustomRuleConfirmFooterStep = {
|
||||
};
|
||||
|
||||
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;
|
||||
CUSTOM_RULE_FACETS.filter((r) => r.footerMessageKey != null).map((r) => ({
|
||||
step: r.createFlowStep as CustomRuleConfirmFooterStep["step"],
|
||||
footerMessageKey: r.footerMessageKey as FooterMessageKey,
|
||||
selectionIds: r.selectionIds,
|
||||
}));
|
||||
|
||||
export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||
CreateFlowStep,
|
||||
@@ -79,16 +58,9 @@ export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||
export function methodCardFacetSectionForConfirmStep(
|
||||
step: CustomRuleConfirmFooterStep["step"],
|
||||
): CreateFlowMethodCardFacetSection | undefined {
|
||||
switch (step) {
|
||||
case "communication-methods":
|
||||
return "communication";
|
||||
case "membership-methods":
|
||||
return "membership";
|
||||
case "decision-approaches":
|
||||
return "decisionApproaches";
|
||||
case "conflict-management":
|
||||
return "conflictManagement";
|
||||
default:
|
||||
return undefined;
|
||||
const row = CUSTOM_RULE_FACETS.find((r) => r.createFlowStep === step);
|
||||
if (row == null || row.kind !== "method" || row.apiMethodSectionId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return row.apiMethodSectionId;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
|
||||
import { createFlowStepForCustomRuleFacetGroup } from "../../../../lib/create/customRuleFacets";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
const MAP: Record<TemplateFacetGroupKey, CreateFlowStep> = {
|
||||
coreValues: "core-values",
|
||||
communication: "communication-methods",
|
||||
membership: "membership-methods",
|
||||
decisionApproaches: "decision-approaches",
|
||||
conflictManagement: "conflict-management",
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom-rule URL segment for a final-review category row (`+` navigation).
|
||||
* Source: {@link CUSTOM_RULE_FACETS} (CR-92).
|
||||
*/
|
||||
export function createFlowStepForFacetGroup(
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): CreateFlowStep {
|
||||
return MAP[groupKey];
|
||||
return createFlowStepForCustomRuleFacetGroup(groupKey);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,26 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
||||
return FLOW_STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps where below `lg` the main column scrolls with split layout
|
||||
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
||||
*/
|
||||
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
||||
"community-size",
|
||||
"community-structure",
|
||||
"core-values",
|
||||
"decision-approaches",
|
||||
] as const;
|
||||
|
||||
export function createFlowStepUsesSelectSplitScroll(
|
||||
step: CreateFlowStep | null | undefined,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return (CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS as readonly string[]).includes(
|
||||
step,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given string is a valid create flow step
|
||||
*/
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ShareView = memo(function ShareView({
|
||||
onSlackShare,
|
||||
onDiscordShare,
|
||||
className = "",
|
||||
backdropVariant = "default",
|
||||
backdropVariant = "blurredYellow",
|
||||
dialogRef,
|
||||
overlayRef,
|
||||
titleId,
|
||||
|
||||
@@ -47,6 +47,14 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt
|
||||
|
||||
Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/(app)/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/(app)/create/hooks/useCreateFlowNavigation.ts).
|
||||
|
||||
### Custom-rule facet catalog and `/create` path helpers (CR-92)
|
||||
|
||||
**[`CUSTOM_RULE_FACETS`](../lib/create/customRuleFacets.ts)** is the single table for template category keys, `CreateFlowStep`, footer confirm bindings, `selected*` / strip keys, and the four `GET /api/create-flow/methods?section=` ids. Update that module (and related tests) when adding or renaming a custom-rule facet instead of scattering new switches across `applyTemplatePrefill`, `customRuleConfirmFooterSteps`, `buildFinalReviewCategories`, `stripCustomRuleSelectionFields`, etc.
|
||||
|
||||
**[`createFlowPaths`](../app/(app)/create/utils/createFlowPaths.ts)** centralises `/create/{step}` builders and shared constants (`CREATE_ROUTES`, `syncDraft` helpers). Prefer these over raw string literals in layout, hooks, and redirects.
|
||||
|
||||
Wizard step → React screen rendering lives in [`createFlowScreenComponents.tsx`](../app/(app)/create/screens/createFlowScreenComponents.tsx) (`renderCreateFlowScreen`), paired with [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) for Figma/layout metadata.
|
||||
|
||||
### Fresh start vs continue draft (signed-in + sync)
|
||||
|
||||
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||
|
||||
@@ -1,59 +1,39 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type { FinalReviewChipEditPatch } from "../../app/(app)/create/components/FinalReviewChipEditModal";
|
||||
import { CUSTOM_RULE_FACET_BY_GROUP } from "./customRuleFacets";
|
||||
|
||||
/**
|
||||
* `groupKey` cases mirror {@link CUSTOM_RULE_FACETS} / `TemplateFacetGroupKey`
|
||||
* (Linear CR-92 — keep exhaustiveness when adding a facet row).
|
||||
*
|
||||
* Translate a {@link FinalReviewChipEditPatch} into the `Partial<CreateFlowState>`
|
||||
* patch that {@link CreateFlowState}'s update merger should write back. Each
|
||||
* group key targets its own `*DetailsById` (or `coreValueDetailsByChipId`)
|
||||
* record; the patch always merges the new value onto the existing record so
|
||||
* other chips' overrides are preserved.
|
||||
*
|
||||
* The `switch` is exhaustive because {@link FinalReviewChipEditPatch} is a
|
||||
* discriminated union — adding a new facet group in the modal forces a new
|
||||
* `case` here at compile time, which is the whole reason this lives outside
|
||||
* `FinalReviewScreen` (the screen used to host an identical 5-case switch).
|
||||
*
|
||||
* Exported as a pure function so it's unit-testable without React.
|
||||
*/
|
||||
export function applyFinalReviewChipEditPatch(
|
||||
state: CreateFlowState,
|
||||
patch: FinalReviewChipEditPatch,
|
||||
): Partial<CreateFlowState> {
|
||||
switch (patch.groupKey) {
|
||||
case "coreValues":
|
||||
return {
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
case "communication":
|
||||
return {
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
case "membership":
|
||||
return {
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
case "decisionApproaches":
|
||||
return {
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
case "conflictManagement":
|
||||
return {
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
const facet = CUSTOM_RULE_FACET_BY_GROUP.get(patch.groupKey);
|
||||
if (!facet) {
|
||||
throw new Error(
|
||||
`applyFinalReviewChipEditPatch: unknown facet group ${patch.groupKey}`,
|
||||
);
|
||||
}
|
||||
const stateKey = facet.detailOverridesStateKey;
|
||||
const current = state[stateKey];
|
||||
const record =
|
||||
current && typeof current === "object"
|
||||
? (current as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
[stateKey]: {
|
||||
...record,
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
import { assignTemplateMethodSlugsToPrefill } from "./customRuleFacets";
|
||||
import { methodSlugFromTitle } from "./methodSlugFromTitle";
|
||||
|
||||
type TemplateEntry = { title: unknown };
|
||||
@@ -117,28 +118,7 @@ export function buildTemplateCustomizePrefill(
|
||||
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;
|
||||
}
|
||||
assignTemplateMethodSlugsToPrefill(prefill, key, slugs);
|
||||
}
|
||||
|
||||
return prefill;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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 { readMethodPresetsForFacetGroup } from "./customRuleFacets";
|
||||
import {
|
||||
buildCoreValuesForDocument,
|
||||
parseSectionsFromCreateFlowState,
|
||||
@@ -55,21 +52,6 @@ export type FinalReviewCategoryNames = {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ordered list of preset ids to `{label, id}` entries, filtering
|
||||
* missing/duplicate labels. The id is returned alongside so callers can key
|
||||
@@ -115,18 +97,8 @@ function overrideKeyForLabel(
|
||||
function methodsForGroup(
|
||||
groupKey: TemplateFacetGroupKey | null,
|
||||
): readonly MethodPreset[] {
|
||||
switch (groupKey) {
|
||||
case "communication":
|
||||
return readMethodsArray(communicationMessages);
|
||||
case "membership":
|
||||
return readMethodsArray(membershipMessages);
|
||||
case "decisionApproaches":
|
||||
return readMethodsArray(decisionApproachesMessages);
|
||||
case "conflictManagement":
|
||||
return readMethodsArray(conflictManagementMessages);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
if (groupKey == null) return [];
|
||||
return readMethodPresetsForFacetGroup(groupKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Single source of truth for custom-rule facet dimensions: URL steps, template
|
||||
* category keys, footer confirm bindings, API method sections, and related
|
||||
* state keys (Linear CR-92 §1 — `CUSTOM_RULE_FACETS`).
|
||||
*
|
||||
* Callers: `applyTemplatePrefill`, `customRuleConfirmFooterSteps`,
|
||||
* `stripCustomRuleSelectionFields`, `buildFinalReviewCategories`,
|
||||
* `facetGroupToCreateFlowStep`, `methodFacetsSchemas` (`SECTION_IDS`),
|
||||
* `publishedDocumentToCreateFlowState` (selection keys), pin lists, etc.
|
||||
*/
|
||||
|
||||
import type { CreateFlowState, CreateFlowStep } from "../../app/(app)/create/types";
|
||||
import type footerMessages from "../../messages/en/create/footer.json";
|
||||
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";
|
||||
|
||||
type FooterMessageKey = keyof typeof footerMessages;
|
||||
|
||||
type MethodPreset = { id: string; label: string };
|
||||
|
||||
/**
|
||||
* Known facet groups that template sections map to. Matches the five modals on
|
||||
* the custom-rule create flow (`m.create.customRule.*`).
|
||||
*/
|
||||
export type TemplateFacetGroupKey =
|
||||
| "coreValues"
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const METHOD_MESSAGES: Record<
|
||||
Exclude<TemplateFacetGroupKey, "coreValues">,
|
||||
unknown
|
||||
> = {
|
||||
communication: communicationMessages,
|
||||
membership: membershipMessages,
|
||||
decisionApproaches: decisionApproachesMessages,
|
||||
conflictManagement: conflictManagementMessages,
|
||||
};
|
||||
|
||||
/** API + recommendation `section` param ids (CR-88); excludes core values. */
|
||||
export const METHOD_FACET_API_SECTION_IDS = [
|
||||
"communication",
|
||||
"membership",
|
||||
"decisionApproaches",
|
||||
"conflictManagement",
|
||||
] as const;
|
||||
|
||||
export type MethodFacetApiSectionId = (typeof METHOD_FACET_API_SECTION_IDS)[number];
|
||||
|
||||
export type CustomRuleFacetKind = "coreValues" | "method";
|
||||
|
||||
export type CustomRuleFacetRow = {
|
||||
readonly facetGroupKey: TemplateFacetGroupKey;
|
||||
readonly kind: CustomRuleFacetKind;
|
||||
readonly createFlowStep: CreateFlowStep;
|
||||
/**
|
||||
* Normalised template `categoryName` keys (see `applyTemplatePrefill` /
|
||||
* `templateCategoryToGroupKey`) — which headers map to this facet.
|
||||
*/
|
||||
readonly templateCategoryNormalizedKeys: readonly string[];
|
||||
/** Footer primary on confirm steps; `null` if this row is not in that table. */
|
||||
readonly footerMessageKey: FooterMessageKey | null;
|
||||
readonly selectionIds: (state: CreateFlowState) => readonly string[];
|
||||
/** Primary selection array on `CreateFlowState` (hydrate + published checks). */
|
||||
readonly selectedIdsStateKey: keyof CreateFlowState;
|
||||
/**
|
||||
* Per-chip edit overrides map (`FinalReviewChipEditPatch` target) keyed by
|
||||
* chip/preset id, e.g. `communicationMethodDetailsById`.
|
||||
*/
|
||||
readonly detailOverridesStateKey: keyof CreateFlowState;
|
||||
/** Keys removed by `stripCustomRuleSelectionFields` for this facet. */
|
||||
readonly stripSelectionKeys: readonly (keyof CreateFlowState)[];
|
||||
/** `GET /api/create-flow/methods?section=` — only for `kind === "method"`. */
|
||||
readonly apiMethodSectionId: MethodFacetApiSectionId | null;
|
||||
};
|
||||
|
||||
const coreValuesRow = {
|
||||
facetGroupKey: "coreValues",
|
||||
kind: "coreValues",
|
||||
createFlowStep: "core-values",
|
||||
templateCategoryNormalizedKeys: ["values", "corevalues"] as const,
|
||||
footerMessageKey: "confirmCoreValues",
|
||||
selectionIds: (s: CreateFlowState) => s.selectedCoreValueIds ?? [],
|
||||
selectedIdsStateKey: "selectedCoreValueIds",
|
||||
detailOverridesStateKey: "coreValueDetailsByChipId",
|
||||
stripSelectionKeys: [
|
||||
"selectedCoreValueIds",
|
||||
"coreValuesChipsSnapshot",
|
||||
"coreValueDetailsByChipId",
|
||||
] as const,
|
||||
apiMethodSectionId: null,
|
||||
} satisfies CustomRuleFacetRow;
|
||||
|
||||
const communicationRow = {
|
||||
facetGroupKey: "communication",
|
||||
kind: "method",
|
||||
createFlowStep: "communication-methods",
|
||||
templateCategoryNormalizedKeys: ["communication", "communications"] as const,
|
||||
footerMessageKey: "confirmCommunication",
|
||||
selectionIds: (s: CreateFlowState) => s.selectedCommunicationMethodIds ?? [],
|
||||
selectedIdsStateKey: "selectedCommunicationMethodIds",
|
||||
detailOverridesStateKey: "communicationMethodDetailsById",
|
||||
stripSelectionKeys: ["selectedCommunicationMethodIds"] as const,
|
||||
apiMethodSectionId: "communication",
|
||||
} satisfies CustomRuleFacetRow;
|
||||
|
||||
const membershipRow = {
|
||||
facetGroupKey: "membership",
|
||||
kind: "method",
|
||||
createFlowStep: "membership-methods",
|
||||
templateCategoryNormalizedKeys: ["membership", "memberships"] as const,
|
||||
footerMessageKey: "confirmMembership",
|
||||
selectionIds: (s: CreateFlowState) => s.selectedMembershipMethodIds ?? [],
|
||||
selectedIdsStateKey: "selectedMembershipMethodIds",
|
||||
detailOverridesStateKey: "membershipMethodDetailsById",
|
||||
stripSelectionKeys: ["selectedMembershipMethodIds"] as const,
|
||||
apiMethodSectionId: "membership",
|
||||
} satisfies CustomRuleFacetRow;
|
||||
|
||||
const decisionRow = {
|
||||
facetGroupKey: "decisionApproaches",
|
||||
kind: "method",
|
||||
createFlowStep: "decision-approaches",
|
||||
templateCategoryNormalizedKeys: [
|
||||
"decisionmaking",
|
||||
"decisionapproaches",
|
||||
"decisions",
|
||||
] as const,
|
||||
footerMessageKey: "confirmDecisionApproaches",
|
||||
selectionIds: (s: CreateFlowState) => s.selectedDecisionApproachIds ?? [],
|
||||
selectedIdsStateKey: "selectedDecisionApproachIds",
|
||||
detailOverridesStateKey: "decisionApproachDetailsById",
|
||||
stripSelectionKeys: ["selectedDecisionApproachIds"] as const,
|
||||
apiMethodSectionId: "decisionApproaches",
|
||||
} satisfies CustomRuleFacetRow;
|
||||
|
||||
const conflictRow = {
|
||||
facetGroupKey: "conflictManagement",
|
||||
kind: "method",
|
||||
createFlowStep: "conflict-management",
|
||||
templateCategoryNormalizedKeys: [
|
||||
"conflictmanagement",
|
||||
"conflict",
|
||||
"conflictresolution",
|
||||
] as const,
|
||||
footerMessageKey: "confirmConflictManagement",
|
||||
selectionIds: (s: CreateFlowState) => s.selectedConflictManagementIds ?? [],
|
||||
selectedIdsStateKey: "selectedConflictManagementIds",
|
||||
detailOverridesStateKey: "conflictManagementDetailsById",
|
||||
stripSelectionKeys: ["selectedConflictManagementIds"] as const,
|
||||
apiMethodSectionId: "conflictManagement",
|
||||
} satisfies CustomRuleFacetRow;
|
||||
|
||||
/**
|
||||
* Ordered facet rows: core values first, then the four method groups (matches
|
||||
* footer confirm order and typical wizard progression).
|
||||
*/
|
||||
export const CUSTOM_RULE_FACETS: readonly CustomRuleFacetRow[] = [
|
||||
coreValuesRow,
|
||||
communicationRow,
|
||||
membershipRow,
|
||||
decisionRow,
|
||||
conflictRow,
|
||||
] as const;
|
||||
|
||||
export const CUSTOM_RULE_FACET_BY_GROUP: ReadonlyMap<
|
||||
TemplateFacetGroupKey,
|
||||
CustomRuleFacetRow
|
||||
> = new Map(CUSTOM_RULE_FACETS.map((r) => [r.facetGroupKey, r]));
|
||||
|
||||
/** Keys cleared by {@link stripCustomRuleSelectionFields} (plus pin map). */
|
||||
export const STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS: readonly (keyof CreateFlowState)[] =
|
||||
[
|
||||
...CUSTOM_RULE_FACETS.flatMap((r) => [...r.stripSelectionKeys]),
|
||||
"methodSectionsPinCommitted",
|
||||
];
|
||||
|
||||
/** `selected*` keys used when merging published rule selections into draft. */
|
||||
export const PUBLISHED_CUSTOM_RULE_SELECTION_KEYS: readonly (keyof CreateFlowState)[] =
|
||||
CUSTOM_RULE_FACETS.map((r) => r.selectedIdsStateKey);
|
||||
|
||||
export function readMethodPresetsForFacetGroup(
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): readonly MethodPreset[] {
|
||||
if (groupKey === "coreValues") return [];
|
||||
return readMethodsArray(METHOD_MESSAGES[groupKey]);
|
||||
}
|
||||
|
||||
export function assignTemplateMethodSlugsToPrefill(
|
||||
prefill: Partial<CreateFlowState>,
|
||||
normalizedCategoryKey: string,
|
||||
slugs: string[],
|
||||
): boolean {
|
||||
for (const row of CUSTOM_RULE_FACETS) {
|
||||
if (row.kind !== "method") continue;
|
||||
if (!row.templateCategoryNormalizedKeys.includes(normalizedCategoryKey)) {
|
||||
continue;
|
||||
}
|
||||
const k = row.selectedIdsStateKey;
|
||||
(prefill as Record<string, unknown>)[k] = slugs;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createFlowStepForCustomRuleFacetGroup(
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): CreateFlowStep {
|
||||
const row = CUSTOM_RULE_FACET_BY_GROUP.get(groupKey);
|
||||
if (!row) {
|
||||
throw new Error(`customRuleFacets: unknown group ${groupKey}`);
|
||||
}
|
||||
return row.createFlowStep;
|
||||
}
|
||||
|
||||
export function templateCategoryToFacetGroupKey(
|
||||
categoryName: string,
|
||||
): TemplateFacetGroupKey | null {
|
||||
const key = categoryName.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
for (const row of CUSTOM_RULE_FACETS) {
|
||||
if (row.templateCategoryNormalizedKeys.includes(key)) {
|
||||
return row.facetGroupKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2,17 +2,13 @@ import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import {
|
||||
CUSTOM_RULE_FACETS,
|
||||
PUBLISHED_CUSTOM_RULE_SELECTION_KEYS,
|
||||
} from "./customRuleFacets";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
|
||||
const PUBLISHED_SELECTION_FIELD_KEYS: readonly (keyof CreateFlowState)[] = [
|
||||
"selectedCoreValueIds",
|
||||
"selectedCommunicationMethodIds",
|
||||
"selectedMembershipMethodIds",
|
||||
"selectedDecisionApproachIds",
|
||||
"selectedConflictManagementIds",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* True when `patch` (from {@link createFlowStateFromPublishedRule}) expects
|
||||
* non-empty facet selections but `state` still has none for that facet.
|
||||
@@ -25,7 +21,7 @@ export function isPublishedRuleSelectionMissing(
|
||||
state: CreateFlowState,
|
||||
patch: Partial<CreateFlowState>,
|
||||
): boolean {
|
||||
for (const k of PUBLISHED_SELECTION_FIELD_KEYS) {
|
||||
for (const k of PUBLISHED_CUSTOM_RULE_SELECTION_KEYS) {
|
||||
const desired = patch[k];
|
||||
if (!Array.isArray(desired) || desired.length === 0) continue;
|
||||
const actualRaw = state[k];
|
||||
@@ -49,17 +45,12 @@ export function methodSectionsPinsForHydratedSelections(
|
||||
patch: Partial<CreateFlowState>,
|
||||
): Partial<Record<CreateFlowMethodCardFacetSection, boolean>> {
|
||||
const out: Partial<Record<CreateFlowMethodCardFacetSection, boolean>> = {};
|
||||
if ((patch.selectedCommunicationMethodIds?.length ?? 0) > 0) {
|
||||
out.communication = true;
|
||||
}
|
||||
if ((patch.selectedMembershipMethodIds?.length ?? 0) > 0) {
|
||||
out.membership = true;
|
||||
}
|
||||
if ((patch.selectedDecisionApproachIds?.length ?? 0) > 0) {
|
||||
out.decisionApproaches = true;
|
||||
}
|
||||
if ((patch.selectedConflictManagementIds?.length ?? 0) > 0) {
|
||||
out.conflictManagement = true;
|
||||
for (const row of CUSTOM_RULE_FACETS) {
|
||||
if (row.kind !== "method" || row.apiMethodSectionId == null) continue;
|
||||
const sel = patch[row.selectedIdsStateKey];
|
||||
if (Array.isArray(sel) && sel.length > 0) {
|
||||
out[row.apiMethodSectionId] = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import { STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS } from "./customRuleFacets";
|
||||
|
||||
/**
|
||||
* Same field removal as {@link resetCustomRuleSelections} in CreateFlowProvider.
|
||||
* Used to apply template "Use without changes" in one atomic replaceState updater.
|
||||
*
|
||||
* Keys come from {@link CUSTOM_RULE_FACETS} / {@link STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS}
|
||||
* (Linear CR-92).
|
||||
*/
|
||||
export function stripCustomRuleSelectionFields(
|
||||
prev: CreateFlowState,
|
||||
): CreateFlowState {
|
||||
const {
|
||||
selectedCoreValueIds: _a,
|
||||
coreValuesChipsSnapshot: _b,
|
||||
coreValueDetailsByChipId: _c,
|
||||
selectedCommunicationMethodIds: _d,
|
||||
selectedMembershipMethodIds: _e,
|
||||
selectedDecisionApproachIds: _f,
|
||||
selectedConflictManagementIds: _g,
|
||||
methodSectionsPinCommitted: _h,
|
||||
...rest
|
||||
} = prev;
|
||||
return rest;
|
||||
const out: CreateFlowState = { ...prev };
|
||||
for (const key of STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS) {
|
||||
delete (out as Record<string, unknown>)[key as string];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Category } from "../../app/components/cards/Rule";
|
||||
import type { ChipOption } from "../../app/components/controls/MultiSelect/MultiSelect.types";
|
||||
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { TemplateFacetGroupKey } from "./customRuleFacets";
|
||||
import { templateCategoryToFacetGroupKey } from "./customRuleFacets";
|
||||
import { isDocumentEntry } from "./documentEntryGuards";
|
||||
|
||||
export type { TemplateFacetGroupKey } from "./customRuleFacets";
|
||||
|
||||
function isDocumentSection(x: unknown): x is CommunityRuleSection {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
@@ -11,17 +15,6 @@ function isDocumentSection(x: unknown): x is CommunityRuleSection {
|
||||
return o.entries.every(isDocumentEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Known facet groups that template sections map to. Matches the five modals on
|
||||
* the custom-rule create flow (`m.create.customRule.*`).
|
||||
*/
|
||||
export type TemplateFacetGroupKey =
|
||||
| "coreValues"
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
|
||||
/**
|
||||
* Normalize a section `categoryName` (as it appears in a template's `body`)
|
||||
* to the custom-rule facet-group key. Returns `null` for unknown categories.
|
||||
@@ -31,28 +24,7 @@ export type TemplateFacetGroupKey =
|
||||
export function templateCategoryToGroupKey(
|
||||
categoryName: string,
|
||||
): TemplateFacetGroupKey | null {
|
||||
const key = categoryName.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
switch (key) {
|
||||
case "values":
|
||||
case "corevalues":
|
||||
return "coreValues";
|
||||
case "communication":
|
||||
case "communications":
|
||||
return "communication";
|
||||
case "membership":
|
||||
case "memberships":
|
||||
return "membership";
|
||||
case "decisionmaking":
|
||||
case "decisionapproaches":
|
||||
case "decisions":
|
||||
return "decisionApproaches";
|
||||
case "conflictmanagement":
|
||||
case "conflict":
|
||||
case "conflictresolution":
|
||||
return "conflictManagement";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return templateCategoryToFacetGroupKey(categoryName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { METHOD_FACET_API_SECTION_IDS } from "../../create/customRuleFacets";
|
||||
|
||||
/**
|
||||
* Zod schemas for the recommendation matrix (CR-88).
|
||||
@@ -14,12 +15,8 @@ import { z } from "zod";
|
||||
* facet values), §6 (JSON shape), §7 (`MethodFacet` schema), §9 (API).
|
||||
*/
|
||||
|
||||
export const SECTION_IDS = [
|
||||
"communication",
|
||||
"membership",
|
||||
"decisionApproaches",
|
||||
"conflictManagement",
|
||||
] as const;
|
||||
/** Canonical ids — source: {@link METHOD_FACET_API_SECTION_IDS} in `lib/create/customRuleFacets.ts`. */
|
||||
export const SECTION_IDS = METHOD_FACET_API_SECTION_IDS;
|
||||
export type SectionId = (typeof SECTION_IDS)[number];
|
||||
export const sectionIdSchema = z.enum(SECTION_IDS);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||
CREATE_ROUTES,
|
||||
createFlowStepPath,
|
||||
createFlowStepPathAfterStrippingReviewReturn,
|
||||
createFlowStepPathWithSyncDraft,
|
||||
} from "../../app/(app)/create/utils/createFlowPaths";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
describe("createFlowPaths (CR-92 §2)", () => {
|
||||
it("createFlowStepPath builds segment path", () => {
|
||||
expect(createFlowStepPath("review")).toBe("/create/review");
|
||||
});
|
||||
|
||||
it("createFlowStepPath encodes query", () => {
|
||||
expect(
|
||||
createFlowStepPath("completed", { celebrate: "1", foo: "bar" }),
|
||||
).toBe("/create/completed?celebrate=1&foo=bar");
|
||||
});
|
||||
|
||||
it("createFlowStepPathWithSyncDraft", () => {
|
||||
expect(createFlowStepPathWithSyncDraft("final-review")).toBe(
|
||||
`/create/final-review?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn only", () => {
|
||||
const sp = new URLSearchParams(
|
||||
`a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&b=2`,
|
||||
);
|
||||
expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe(
|
||||
"/create/final-review?a=1&b=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("CREATE_ROUTES constants", () => {
|
||||
expect(CREATE_ROUTES.review).toBe("/create/review");
|
||||
expect(CREATE_ROUTES.completed).toBe("/create/completed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CREATE_FLOW_SCREEN_REGISTRY } from "../../app/(app)/create/utils/createFlowScreenRegistry";
|
||||
import { VALID_STEPS } from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
describe("create flow registry vs valid steps (CR-92 §3)", () => {
|
||||
it("CREATE_FLOW_SCREEN_REGISTRY defines every VALID_STEPS id", () => {
|
||||
const keys = new Set(Object.keys(CREATE_FLOW_SCREEN_REGISTRY));
|
||||
for (const step of VALID_STEPS) {
|
||||
expect(keys.has(step), `missing registry entry for ${step}`).toBe(true);
|
||||
}
|
||||
expect(keys.size).toBe(VALID_STEPS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assignTemplateMethodSlugsToPrefill,
|
||||
createFlowStepForCustomRuleFacetGroup,
|
||||
CUSTOM_RULE_FACETS,
|
||||
CUSTOM_RULE_FACET_BY_GROUP,
|
||||
METHOD_FACET_API_SECTION_IDS,
|
||||
PUBLISHED_CUSTOM_RULE_SELECTION_KEYS,
|
||||
readMethodPresetsForFacetGroup,
|
||||
STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS,
|
||||
} from "../../lib/create/customRuleFacets";
|
||||
import { SECTION_IDS } from "../../lib/server/validation/methodFacetsSchemas";
|
||||
import type { CreateFlowStep } from "../../app/(app)/create/types";
|
||||
|
||||
describe("customRuleFacets (CR-92)", () => {
|
||||
it("METHOD_FACET_API_SECTION_IDS matches API SECTION_IDS", () => {
|
||||
expect([...METHOD_FACET_API_SECTION_IDS]).toEqual([...SECTION_IDS]);
|
||||
});
|
||||
|
||||
it("has five rows in stable order", () => {
|
||||
expect(CUSTOM_RULE_FACETS.map((r) => r.facetGroupKey)).toEqual([
|
||||
"coreValues",
|
||||
"communication",
|
||||
"membership",
|
||||
"decisionApproaches",
|
||||
"conflictManagement",
|
||||
]);
|
||||
});
|
||||
|
||||
it("facet group map covers every TemplateFacetGroupKey", () => {
|
||||
for (const row of CUSTOM_RULE_FACETS) {
|
||||
expect(CUSTOM_RULE_FACET_BY_GROUP.get(row.facetGroupKey)).toBe(row);
|
||||
}
|
||||
});
|
||||
|
||||
it("createFlowStepForCustomRuleFacetGroup matches footer steps", () => {
|
||||
const steps = new Set<CreateFlowStep>();
|
||||
for (const row of CUSTOM_RULE_FACETS) {
|
||||
steps.add(createFlowStepForCustomRuleFacetGroup(row.facetGroupKey));
|
||||
}
|
||||
expect(steps.has("core-values")).toBe(true);
|
||||
expect(steps.has("communication-methods")).toBe(true);
|
||||
expect(steps.has("membership-methods")).toBe(true);
|
||||
expect(steps.has("decision-approaches")).toBe(true);
|
||||
expect(steps.has("conflict-management")).toBe(true);
|
||||
});
|
||||
|
||||
it("assignTemplateMethodSlugsToPrefill maps normalised keys", () => {
|
||||
const prefill: Record<string, unknown> = {};
|
||||
expect(assignTemplateMethodSlugsToPrefill(prefill, "communications", ["a"])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(prefill.selectedCommunicationMethodIds).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("published selection keys are the five selected* fields", () => {
|
||||
expect(PUBLISHED_CUSTOM_RULE_SELECTION_KEYS).toEqual([
|
||||
"selectedCoreValueIds",
|
||||
"selectedCommunicationMethodIds",
|
||||
"selectedMembershipMethodIds",
|
||||
"selectedDecisionApproachIds",
|
||||
"selectedConflictManagementIds",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strip keys include method pins once", () => {
|
||||
const pinCount = STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS.filter(
|
||||
(k) => k === "methodSectionsPinCommitted",
|
||||
).length;
|
||||
expect(pinCount).toBe(1);
|
||||
});
|
||||
|
||||
it("readMethodPresetsForFacetGroup returns ids for communication", () => {
|
||||
const m = readMethodPresetsForFacetGroup("communication");
|
||||
expect(m.length).toBeGreaterThan(0);
|
||||
expect(typeof m[0]!.id).toBe("string");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user