Create flow centralization and cleanup

This commit is contained in:
adilallo
2026-04-30 08:11:55 -06:00
parent a37a72c71d
commit b7446873cd
26 changed files with 709 additions and 361 deletions
+21 -41
View File
@@ -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,
},
};
}
+2 -22
View File
@@ -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;
+3 -31
View File
@@ -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);
}
/**
+245
View File
@@ -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;
}
+9 -12
View File
@@ -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;
}
+5 -33
View File
@@ -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);
}
/**
+3 -6
View File
@@ -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);