From ac1157a1724d2c49821e60297a851f7f51d6d84f Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:02:47 -0600 Subject: [PATCH] Persist choices through to completed page --- .../create/hooks/useTemplateReviewActions.ts | 5 - .../screens/completed/CompletedScreen.tsx | 6 +- .../screens/review/FinalReviewScreen.tsx | 3 +- app/(marketing)/rules/[id]/page.tsx | 4 +- docs/create-flow.md | 4 +- lib/create/buildFinalReviewCategories.ts | 12 + lib/create/buildPublishPayload.ts | 104 ++++++- lib/create/finalReviewChipPresets.ts | 44 +++ .../publishedDocumentToDisplaySections.ts | 282 ++++++++++++++++++ .../ruleSectionsFromMethodSelections.ts | 194 ++++++++++++ prisma/seed.ts | 7 +- tests/unit/buildPublishPayload.test.ts | 50 +++- ...publishedDocumentToDisplaySections.test.ts | 119 ++++++++ 13 files changed, 804 insertions(+), 30 deletions(-) create mode 100644 lib/create/publishedDocumentToDisplaySections.ts create mode 100644 lib/create/ruleSectionsFromMethodSelections.ts create mode 100644 tests/unit/publishedDocumentToDisplaySections.test.ts diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index 970f35c..2bc3bbc 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -163,16 +163,11 @@ export function useTemplateReviewActions({ }) : sections; - const summaryRaw = - typeof template.description === "string" - ? template.description.trim() - : ""; const hasCommunityName = typeof state.title === "string" && state.title.trim().length > 0; updateState({ ...coreValuesPrefill, sections: sectionsWithoutValues, - ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), templateReviewBackSlug: templateReviewSlug, ...(hasCommunityName ? { pendingTemplateAction: undefined } diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index 7b615ac..b0d06e7 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -7,7 +7,7 @@ import type { CommunityRuleSection } from "../../../../components/type/Community import Alert from "../../../../components/modals/Alert"; import { useMessages } from "../../../../contexts/MessagesContext"; import { fetchPublishedRuleDetail } from "../../../../../lib/create/api"; -import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections"; import { readLastPublishedRule, writeLastPublishedRule, @@ -48,7 +48,7 @@ function initialCompletedUi( documentSections: [], }; } - const parsed = parseDocumentSectionsForDisplay(stored.document); + const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document); if (parsed.length === 0) { return { headerTitle: "", @@ -105,7 +105,7 @@ export function CompletedScreen() { summary: detail.rule.summary, document: doc, }); - const parsed = parseDocumentSectionsForDisplay(doc); + const parsed = parsePublishedDocumentForCommunityRuleDisplay(doc); if (parsed.length === 0) { router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`); return; diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index e104cc5..118be5b 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -170,8 +170,7 @@ export function FinalReviewScreen() { /** * Match {@link CommunityReviewScreen}: the card body is the free-text - * `community-context` field only — not `summary` (template / one-line - * rule summary can carry template-review copy). + * `community-context` field only — not `summary`. */ const ruleCardDescription = useMemo(() => { const raw = diff --git a/app/(marketing)/rules/[id]/page.tsx b/app/(marketing)/rules/[id]/page.tsx index ee6fa8b..eef4bfa 100644 --- a/app/(marketing)/rules/[id]/page.tsx +++ b/app/(marketing)/rules/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; -import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections"; import CommunityRule from "../../../components/type/CommunityRule"; import HeaderLockup from "../../../components/type/HeaderLockup"; @@ -49,7 +49,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) { notFound(); } - const sections = parseDocumentSectionsForDisplay(rule.document); + const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document); const description = typeof rule.summary === "string" && rule.summary.trim().length > 0 ? rule.summary diff --git a/docs/create-flow.md b/docs/create-flow.md index 232c7b9..744c94c 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -67,9 +67,9 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c 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`. +**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. 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. +**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` 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. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the user’s community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror. diff --git a/lib/create/buildFinalReviewCategories.ts b/lib/create/buildFinalReviewCategories.ts index 743f3b0..1ebd3df 100644 --- a/lib/create/buildFinalReviewCategories.ts +++ b/lib/create/buildFinalReviewCategories.ts @@ -129,6 +129,18 @@ function methodsForGroup( } } +/** + * Resolve a preset method id from a chip label (template sections / display + * enrichment where entries carry titles but not stable ids). + */ +export function resolveMethodPresetIdFromLabel( + label: string, + groupKey: TemplateFacetGroupKey, +): string | null { + if (groupKey === "coreValues") return null; + return overrideKeyForLabel(label, methodsForGroup(groupKey)); +} + /** * Detailed builder: same logic as {@link buildFinalReviewCategoriesFromState} * but each chip is returned with its `overrideKey` + `groupKey` so the diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index a12e834..301bde8 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -1,20 +1,23 @@ import type { CommunicationMethodDetailEntry, ConflictManagementDetailEntry, - CoreValueDetailEntry, CreateFlowState, DecisionApproachDetailEntry, MembershipMethodDetailEntry, } from "../../app/(app)/create/types"; import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types"; +import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories"; import { communicationPresetFor, conflictManagementPresetFor, decisionApproachPresetFor, membershipPresetFor, + mergeCoreValueDetailWithPresets, methodLabelFor, } from "./finalReviewChipPresets"; import { isDocumentEntry } from "./documentEntryGuards"; +import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections"; +import { templateCategoryToGroupKey } from "./templateReviewMapping"; export { isDocumentEntry } from "./documentEntryGuards"; @@ -53,12 +56,12 @@ export function buildCoreValuesForDocument(state: CreateFlowState): Array<{ return snap .filter((r) => selected.has(r.id)) .map((r) => { - const d: CoreValueDetailEntry | undefined = details[r.id]; + const merged = mergeCoreValueDetailWithPresets(r.id, r.label, details[r.id]); return { chipId: r.id, label: r.label, - meaning: d?.meaning ?? "", - signals: d?.signals ?? "", + meaning: merged.meaning, + signals: merged.signals, }; }); } @@ -102,7 +105,9 @@ export type BuildPublishPayloadResult = } | { ok: false; error: string }; -const FALLBACK_CATEGORY = "Overview"; +export const PUBLISH_FALLBACK_OVERVIEW_CATEGORY = "Overview"; + +const FALLBACK_CATEGORY = PUBLISH_FALLBACK_OVERVIEW_CATEGORY; const DEFAULT_FALLBACK_BODY = "This CommunityRule was created in the create flow. Add more detail in a future edit."; @@ -124,7 +129,8 @@ export function buildPublishPayload( return undefined; }; - let summary = firstNonEmpty(state.summary, state.communityContext); + /** Community context wins over `summary` (template review no longer copies template description into `summary`). */ + let summary = firstNonEmpty(state.communityContext, state.summary); let sections = parseSectionsFromCreateFlowState(state); if (sections.length === 0) { @@ -138,8 +144,18 @@ export function buildPublishPayload( } const coreValues = buildCoreValuesForDocument(state); + if (coreValues.length > 0) { + sections = sections.filter( + (s) => templateCategoryToGroupKey(s.categoryName) !== "coreValues", + ); + } + const methodSelections = buildMethodSelectionsForDocument(state); + if (hasAnyMethodSelection(methodSelections)) { + sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections); + } + const document: Record = { sections, coreValues }; if (hasAnyMethodSelection(methodSelections)) { document.methodSelections = methodSelections; @@ -160,6 +176,59 @@ function hasAnyMethodSelection(m: PublishedMethodSelections): boolean { ); } +function deriveMethodPresetIdsFromSections( + sections: CommunityRuleSection[], +): { + communication: string[]; + membership: string[]; + decisionApproaches: string[]; + conflictManagement: string[]; +} { + const out = { + communication: [] as string[], + membership: [] as string[], + decisionApproaches: [] as string[], + conflictManagement: [] as string[], + }; + for (const s of sections) { + const gk = templateCategoryToGroupKey(s.categoryName); + if (!gk || gk === "coreValues") continue; + const ids: string[] = []; + for (const e of s.entries) { + const title = typeof e.title === "string" ? e.title.trim() : ""; + if (title.length === 0) continue; + const id = resolveMethodPresetIdFromLabel(title, gk); + if (id) ids.push(id); + } + if (ids.length === 0) continue; + switch (gk) { + case "communication": + out.communication = ids; + break; + case "membership": + out.membership = ids; + break; + case "decisionApproaches": + out.decisionApproaches = ids; + break; + case "conflictManagement": + out.conflictManagement = ids; + break; + default: + break; + } + } + return out; +} + +function pickMethodIds( + fromState: string[] | undefined, + derived: string[], +): string[] { + if (fromState && fromState.length > 0) return fromState; + return derived; +} + /** * Merge `selected*MethodIds` with any saved `{group}MethodDetailsById` * overrides authored on the final-review screen. Preset defaults from the @@ -170,9 +239,15 @@ function hasAnyMethodSelection(m: PublishedMethodSelections): boolean { export function buildMethodSelectionsForDocument( state: CreateFlowState, ): PublishedMethodSelections { + const derived = deriveMethodPresetIdsFromSections( + parseSectionsFromCreateFlowState(state), + ); const out: PublishedMethodSelections = {}; - const commIds = state.selectedCommunicationMethodIds ?? []; + const commIds = pickMethodIds( + state.selectedCommunicationMethodIds, + derived.communication, + ); if (commIds.length > 0) { out.communication = commIds.map((id) => { const preset = communicationPresetFor(id); @@ -185,7 +260,10 @@ export function buildMethodSelectionsForDocument( }); } - const memIds = state.selectedMembershipMethodIds ?? []; + const memIds = pickMethodIds( + state.selectedMembershipMethodIds, + derived.membership, + ); if (memIds.length > 0) { out.membership = memIds.map((id) => { const preset = membershipPresetFor(id); @@ -198,7 +276,10 @@ export function buildMethodSelectionsForDocument( }); } - const daIds = state.selectedDecisionApproachIds ?? []; + const daIds = pickMethodIds( + state.selectedDecisionApproachIds, + derived.decisionApproaches, + ); if (daIds.length > 0) { out.decisionApproaches = daIds.map((id) => { const preset = decisionApproachPresetFor(id); @@ -211,7 +292,10 @@ export function buildMethodSelectionsForDocument( }); } - const cmIds = state.selectedConflictManagementIds ?? []; + const cmIds = pickMethodIds( + state.selectedConflictManagementIds, + derived.conflictManagement, + ); if (cmIds.length > 0) { out.conflictManagement = cmIds.map((id) => { const preset = conflictManagementPresetFor(id); diff --git a/lib/create/finalReviewChipPresets.ts b/lib/create/finalReviewChipPresets.ts index 6642d45..03a2f57 100644 --- a/lib/create/finalReviewChipPresets.ts +++ b/lib/create/finalReviewChipPresets.ts @@ -169,6 +169,50 @@ export function coreValuePresetFor(chipId: string): CoreValueDetailEntry { }; } +/** Match `coreValues.json` row by trimmed label (custom chip id / drift fallbacks). */ +export function coreValuePresetForLabel(label: string): CoreValueDetailEntry { + const t = label.trim(); + if (!t) return { meaning: "", signals: "" }; + const values = (coreValuesMessages as { values?: unknown }).values; + if (!Array.isArray(values)) return { meaning: "", signals: "" }; + for (const row of values) { + if (typeof row === "string") { + if (row.trim() === t) return { meaning: "", signals: "" }; + continue; + } + if (!row || typeof row !== "object") continue; + const o = row as Record; + if (typeof o.label === "string" && o.label.trim() === t) { + return { + meaning: asString(o.meaning), + signals: asString(o.signals), + }; + } + } + return { meaning: "", signals: "" }; +} + +/** + * Published / display copy: saved draft wins when non-empty; otherwise preset + * by chip id (numeric presets), then by label match in `coreValues.json`. + */ +export function mergeCoreValueDetailWithPresets( + chipId: string, + label: string, + saved: CoreValueDetailEntry | undefined, +): CoreValueDetailEntry { + const savedMeaning = + typeof saved?.meaning === "string" ? saved.meaning.trim() : ""; + const savedSignals = + typeof saved?.signals === "string" ? saved.signals.trim() : ""; + const fromId = coreValuePresetFor(chipId); + const fromLabel = coreValuePresetForLabel(label); + return { + meaning: savedMeaning || fromId.meaning || fromLabel.meaning, + signals: savedSignals || fromId.signals || fromLabel.signals, + }; +} + /** Resolve method preset label by id for a given group (localized display). */ export function methodLabelFor( groupKey: TemplateFacetGroupKey, diff --git a/lib/create/publishedDocumentToDisplaySections.ts b/lib/create/publishedDocumentToDisplaySections.ts new file mode 100644 index 0000000..82e59d7 --- /dev/null +++ b/lib/create/publishedDocumentToDisplaySections.ts @@ -0,0 +1,282 @@ +import type { + CommunityRuleEntry, + CommunityRuleSection, +} from "../../app/components/type/CommunityRule/CommunityRule.types"; +import type { PublishedMethodSelections } from "./buildPublishPayload"; +import { + PUBLISH_FALLBACK_OVERVIEW_CATEGORY, + parseDocumentSectionsForDisplay, +} from "./buildPublishPayload"; +import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories"; +import { + communicationPresetFor, + conflictManagementPresetFor, + decisionApproachPresetFor, + membershipPresetFor, + mergeCoreValueDetailWithPresets, +} from "./finalReviewChipPresets"; +import { + communityRuleEntryFromMethodChip, + formatScopePayload, + nonEmptyTrimmed, + RULE_SECTION_CATEGORY, + sectionFromCommunication, + sectionFromConflict, + sectionFromDecision, + sectionFromMembership, +} from "./ruleSectionsFromMethodSelections"; +import { + templateCategoryToGroupKey, + type TemplateFacetGroupKey, +} from "./templateReviewMapping"; + +/** Legacy seed placeholder (removed from `prisma/seed.ts`); still hydrate older published rows. */ +const TEMPLATE_COMPOSITION_SUGGESTED_BODY = + "Suggested focus for this governance area. Replace with your own language in the create flow."; + +const CAT_VALUES = RULE_SECTION_CATEGORY.values; +const CAT_COMMUNICATION = RULE_SECTION_CATEGORY.communication; +const CAT_MEMBERSHIP = RULE_SECTION_CATEGORY.membership; +const CAT_DECISION = RULE_SECTION_CATEGORY.decisionMaking; +const CAT_CONFLICT = RULE_SECTION_CATEGORY.conflictManagement; + +const CANONICAL_DISPLAY_SECTION_ORDER = [ + CAT_VALUES, + CAT_COMMUNICATION, + CAT_MEMBERSHIP, + CAT_DECISION, + CAT_CONFLICT, +] as const; + +const COMM_LABELS: Record = { + corePrinciple: "Core Principle & Scope", + logisticsAdmin: "Logistics, Admin & Norms", + codeOfConduct: "Code of Conduct", +}; + +const MEM_LABELS: Record = { + eligibility: "Eligibility & Philosophy", + joiningProcess: "Joining Process", + expectations: "Expectations & Removal", +}; + +const DEC_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", +}; + +const CM_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + processProtocol: "Process Protocol", + restorationFallbacks: "Restoration & Fallbacks", +}; + +function needsPlaceholderPresetEnrichment(entry: CommunityRuleEntry): boolean { + if (entry.blocks && entry.blocks.length > 0) return false; + const b = (entry.body ?? "").trim(); + if (b.length === 0) return true; + return b === TEMPLATE_COMPOSITION_SUGGESTED_BODY; +} + +function presetRecordForMethodGroup( + groupKey: Exclude, + id: string, +): Record { + switch (groupKey) { + case "communication": + return { ...communicationPresetFor(id) } as Record; + case "membership": + return { ...membershipPresetFor(id) } as Record; + case "decisionApproaches": + return { ...decisionApproachPresetFor(id) } as Record; + case "conflictManagement": + return { ...conflictManagementPresetFor(id) } as Record; + } +} + +function enrichMethodEntryIfPlaceholder( + entry: CommunityRuleEntry, + categoryName: string, +): CommunityRuleEntry { + const groupKey = templateCategoryToGroupKey(categoryName); + if (!groupKey || groupKey === "coreValues") return entry; + if (!needsPlaceholderPresetEnrichment(entry)) return entry; + const id = resolveMethodPresetIdFromLabel(entry.title, groupKey); + if (!id) return entry; + const record = presetRecordForMethodGroup(groupKey, id); + const merged: Record = { ...record }; + if (groupKey === "decisionApproaches" || groupKey === "conflictManagement") { + const scope = + formatScopePayload(merged.selectedApplicableScope) ?? + formatScopePayload(merged.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + } + const labelByKey = + groupKey === "communication" + ? COMM_LABELS + : groupKey === "membership" + ? MEM_LABELS + : groupKey === "decisionApproaches" + ? DEC_LABELS + : CM_LABELS; + const options = + groupKey === "decisionApproaches" + ? { consensusLevelKey: "consensusLevel" as const } + : undefined; + const enriched = communityRuleEntryFromMethodChip( + entry.title, + merged, + labelByKey, + options, + ); + return enriched ?? entry; +} + +function enrichCoreValueEntryIfPlaceholder( + entry: CommunityRuleEntry, +): CommunityRuleEntry { + if (!needsPlaceholderPresetEnrichment(entry)) return entry; + const merged = mergeCoreValueDetailWithPresets("", entry.title, { + meaning: "", + signals: "", + }); + const meaning = (merged.meaning ?? "").trim(); + const signals = (merged.signals ?? "").trim(); + const bodyParts: string[] = []; + if (meaning.length > 0) bodyParts.push(meaning); + if (signals.length > 0) bodyParts.push(signals); + const body = bodyParts.join("\n\n"); + if (body.length === 0) return entry; + return { ...entry, body }; +} + +function enrichDisplaySection(section: CommunityRuleSection): CommunityRuleSection { + const groupKey = templateCategoryToGroupKey(section.categoryName); + if (groupKey === "coreValues") { + return { + ...section, + entries: section.entries.map(enrichCoreValueEntryIfPlaceholder), + }; + } + return { + ...section, + entries: section.entries.map((e) => + enrichMethodEntryIfPlaceholder(e, section.categoryName), + ), + }; +} + +function sortSectionsCanonical( + sections: CommunityRuleSection[], +): CommunityRuleSection[] { + const order = CANONICAL_DISPLAY_SECTION_ORDER as readonly string[]; + const rank = (name: string): number => { + const i = order.indexOf(name); + return i === -1 ? order.length : i; + }; + return [...sections].sort((a, b) => { + const d = rank(a.categoryName) - rank(b.categoryName); + if (d !== 0) return d; + return a.categoryName.localeCompare(b.categoryName); + }); +} + +function sectionFromStoredCoreValues( + raw: unknown, +): CommunityRuleSection | null { + if (!Array.isArray(raw) || raw.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const row of raw) { + if (!row || typeof row !== "object") continue; + const o = row as Record; + const chipId = typeof o.chipId === "string" ? o.chipId : ""; + const label = nonEmptyTrimmed(o.label); + if (!label) continue; + const merged = mergeCoreValueDetailWithPresets(chipId, label, { + meaning: typeof o.meaning === "string" ? o.meaning : "", + signals: typeof o.signals === "string" ? o.signals : "", + }); + const meaning = (merged.meaning ?? "").trim(); + const signals = (merged.signals ?? "").trim(); + const bodyParts: string[] = []; + if (meaning.length > 0) bodyParts.push(meaning); + if (signals.length > 0) bodyParts.push(signals); + const body = bodyParts.join("\n\n"); + entries.push({ title: label, body }); + } + if (entries.length === 0) return null; + return { categoryName: CAT_VALUES, entries }; +} + +function parseMethodSelectionsLoose( + document: Record, +): PublishedMethodSelections | null { + const ms = document.methodSelections; + if (!ms || typeof ms !== "object" || Array.isArray(ms)) return null; + return ms as PublishedMethodSelections; +} + +/** + * Full `CommunityRule` sections for a published `document` JSON blob: validated + * `document.sections` plus synthesized categories from `document.coreValues` and + * `document.methodSelections` when those categories are not already present. + * **Overview** sections (see `PUBLISH_FALLBACK_OVERVIEW_CATEGORY` in `buildPublishPayload`) from the publish fallback are dropped so the lockup + * header is the only intro; core value copy is the combined meaning + signals **body** + * under each value **title** (chip label). + */ +export function parsePublishedDocumentForCommunityRuleDisplay( + document: unknown, +): CommunityRuleSection[] { + if (!document || typeof document !== "object") return []; + const doc = document as Record; + + const hasPublishedCoreValues = + Array.isArray(doc.coreValues) && doc.coreValues.length > 0; + + const base = parseDocumentSectionsForDisplay(doc).filter( + (s) => + s.categoryName !== PUBLISH_FALLBACK_OVERVIEW_CATEGORY && + !(hasPublishedCoreValues && s.categoryName === CAT_VALUES), + ); + const seen = new Set(base.map((s) => s.categoryName)); + + const extra: CommunityRuleSection[] = []; + + const valuesSection = sectionFromStoredCoreValues(doc.coreValues); + if (valuesSection && !seen.has(valuesSection.categoryName)) { + extra.push(valuesSection); + seen.add(valuesSection.categoryName); + } + + const methodSelections = parseMethodSelectionsLoose(doc); + if (methodSelections) { + const comm = sectionFromCommunication(methodSelections.communication ?? []); + if (comm && !seen.has(comm.categoryName)) { + extra.push(comm); + seen.add(comm.categoryName); + } + const mem = sectionFromMembership(methodSelections.membership ?? []); + if (mem && !seen.has(mem.categoryName)) { + extra.push(mem); + seen.add(mem.categoryName); + } + const dec = sectionFromDecision(methodSelections.decisionApproaches ?? []); + if (dec && !seen.has(dec.categoryName)) { + extra.push(dec); + seen.add(dec.categoryName); + } + const cm = sectionFromConflict(methodSelections.conflictManagement ?? []); + if (cm && !seen.has(cm.categoryName)) { + extra.push(cm); + seen.add(cm.categoryName); + } + } + + const combined = [...base, ...extra].map(enrichDisplaySection); + return sortSectionsCanonical(combined); +} diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts new file mode 100644 index 0000000..b0d1a4a --- /dev/null +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -0,0 +1,194 @@ +import type { + CommunityRuleEntry, + CommunityRuleLabeledBlock, + CommunityRuleSection, +} from "../../app/components/type/CommunityRule/CommunityRule.types"; +import type { PublishedMethodSelections } from "./buildPublishPayload"; +import { templateCategoryToGroupKey } from "./templateReviewMapping"; + +/** Canonical `categoryName` strings for method groups in published documents. */ +export const RULE_SECTION_CATEGORY = { + values: "Values", + communication: "Communication", + membership: "Membership", + decisionMaking: "Decision-making", + conflictManagement: "Conflict management", +} as const; + +const COMM_LABELS: Record = { + corePrinciple: "Core Principle & Scope", + logisticsAdmin: "Logistics, Admin & Norms", + codeOfConduct: "Code of Conduct", +}; + +const MEM_LABELS: Record = { + eligibility: "Eligibility & Philosophy", + joiningProcess: "Joining Process", + expectations: "Expectations & Removal", +}; + +const DEC_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", +}; + +const CM_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + processProtocol: "Process Protocol", + restorationFallbacks: "Restoration & Fallbacks", +}; + +export function nonEmptyTrimmed(s: unknown): string | null { + if (typeof s !== "string") return null; + const t = s.trim(); + return t.length > 0 ? t : null; +} + +export function formatScopePayload(val: unknown): string | null { + if (typeof val === "string") return nonEmptyTrimmed(val); + if (!Array.isArray(val)) return null; + const lines = val.filter((x): x is string => typeof x === "string" && x.trim().length > 0); + if (lines.length === 0) return null; + return lines.join("\n"); +} + +export function blocksFromKeyedRecord( + sections: Record, + labelByKey: Record, + options?: { + consensusLevelKey?: string; + }, +): CommunityRuleLabeledBlock[] { + const blocks: CommunityRuleLabeledBlock[] = []; + for (const [key, label] of Object.entries(labelByKey)) { + if (options?.consensusLevelKey === key) { + const n = sections[key]; + if (typeof n === "number" && !Number.isNaN(n)) { + blocks.push({ label, body: `${n}%` }); + } + continue; + } + const raw = sections[key]; + const text = + key === "applicableScope" || key === "selectedApplicableScope" + ? formatScopePayload(raw) + : nonEmptyTrimmed(raw); + if (text) blocks.push({ label, body: text }); + } + return blocks; +} + +export function communityRuleEntryFromMethodChip( + title: string, + sections: Record, + labelByKey: Record, + options?: { consensusLevelKey?: string }, +): CommunityRuleEntry | null { + const blocks = blocksFromKeyedRecord(sections, labelByKey, options); + if (blocks.length === 0) return null; + return { title, body: "", blocks }; +} + +export function sectionFromCommunication( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.communication, entries } + : null; +} + +export function sectionFromMembership( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.membership, entries } + : null; +} + +export function sectionFromDecision( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const merged: Record = { ...sec }; + const scope = + formatScopePayload(sec.selectedApplicableScope) ?? + formatScopePayload(sec.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { + consensusLevelKey: "consensusLevel", + }); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.decisionMaking, entries } + : null; +} + +export function sectionFromConflict( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const merged: Record = { ...sec }; + const scope = + formatScopePayload(sec.selectedApplicableScope) ?? + formatScopePayload(sec.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.conflictManagement, entries } + : null; +} + +/** + * Swap template `sections` method rows for fully-resolved entries built from + * `methodSelections` (preset + overrides). + */ +export function replaceMethodSectionsWithMethodSelections( + sections: CommunityRuleSection[], + ms: PublishedMethodSelections, +): CommunityRuleSection[] { + return sections.map((s) => { + const gk = templateCategoryToGroupKey(s.categoryName); + if (gk === "communication" && ms.communication?.length) { + return sectionFromCommunication(ms.communication) ?? s; + } + if (gk === "membership" && ms.membership?.length) { + return sectionFromMembership(ms.membership) ?? s; + } + if (gk === "decisionApproaches" && ms.decisionApproaches?.length) { + return sectionFromDecision(ms.decisionApproaches) ?? s; + } + if (gk === "conflictManagement" && ms.conflictManagement?.length) { + return sectionFromConflict(ms.conflictManagement) ?? s; + } + return s; + }); +} diff --git a/prisma/seed.ts b/prisma/seed.ts index a906b33..06f7f00 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -61,10 +61,7 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue { }; } -/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */ -const COMPOSITION_CHIP_BODY = - "Suggested focus for this governance area. Replace with your own language in the create flow."; - +/** Chip titles from Template Composition.xlsx; bodies stay empty — presets hydrate at publish / display. */ function entriesFromCompositionCell(cell: string): { title: string; body: string }[] { const trimmed = cell.trim(); if (!trimmed) return []; @@ -72,7 +69,7 @@ function entriesFromCompositionCell(cell: string): { title: string; body: string .split(/,\s*/) .map((title) => title.trim()) .filter(Boolean) - .map((title) => ({ title, body: COMPOSITION_CHIP_BODY })); + .map((title) => ({ title, body: "" })); } function bodyFromXlsxComposition(row: { diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts index 060d5c1..053c5a3 100644 --- a/tests/unit/buildPublishPayload.test.ts +++ b/tests/unit/buildPublishPayload.test.ts @@ -4,6 +4,7 @@ import { parseDocumentSectionsForDisplay, parseSectionsFromCreateFlowState, } from "../../lib/create/buildPublishPayload"; +import { mergeCoreValueDetailWithPresets } from "../../lib/create/finalReviewChipPresets"; import type { CreateFlowState } from "../../app/(app)/create/types"; describe("buildPublishPayload", () => { @@ -63,6 +64,17 @@ describe("buildPublishPayload", () => { }); }); + it("prefers communityContext over summary for the published summary field", () => { + const r = buildPublishPayload({ + title: "T", + summary: "One-liner or leftover", + communityContext: " Full community context. ", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.summary).toBe("Full community context."); + }); + it("uses valid state.sections when present", () => { const sections: CreateFlowState["sections"] = [ { @@ -106,9 +118,15 @@ describe("buildPublishPayload", () => { }); expect(r.ok).toBe(true); if (!r.ok) return; + const preset2 = mergeCoreValueDetailWithPresets("2", "Beta", undefined); expect(r.document.coreValues).toEqual([ { chipId: "1", label: "Alpha", meaning: "m1", signals: "s1" }, - { chipId: "2", label: "Beta", meaning: "", signals: "" }, + { + chipId: "2", + label: "Beta", + meaning: preset2.meaning, + signals: preset2.signals, + }, ]); }); }); @@ -121,6 +139,36 @@ describe("buildPublishPayload — methodSelections", () => { expect(r.document.methodSelections).toBeUndefined(); }); + it("derives methodSelections from template sections when selected ids are empty", () => { + const r = buildPublishPayload({ + title: "T", + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Slack", body: "" }], + }, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const ms = r.document.methodSelections as + | { + communication?: Array<{ + id: string; + sections: { corePrinciple: string }; + }>; + } + | undefined; + expect(ms?.communication?.length).toBe(1); + expect(ms?.communication?.[0]?.id).toBe("slack"); + const first = ms?.communication?.[0]; + expect(first?.sections.corePrinciple.length).toBeGreaterThan(10); + const entries = + (r.document.sections as Array<{ entries: Array<{ blocks?: unknown[] }> }>)[0] + ?.entries; + expect(entries?.[0]?.blocks?.length).toBeGreaterThanOrEqual(1); + }); + it("emits preset-only sections when a method is selected without an override", () => { const r = buildPublishPayload({ title: "T", diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts new file mode 100644 index 0000000..adc8625 --- /dev/null +++ b/tests/unit/publishedDocumentToDisplaySections.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../lib/create/publishedDocumentToDisplaySections"; + +describe("parsePublishedDocumentForCommunityRuleDisplay", () => { + it("returns [] for non-object document", () => { + expect(parsePublishedDocumentForCommunityRuleDisplay(null)).toEqual([]); + }); + + it("drops Overview and appends Values + methods with value body text", () => { + const doc = { + sections: [ + { + categoryName: "Overview", + entries: [{ title: "Community", body: "Our river cleanup org." }], + }, + ], + coreValues: [ + { chipId: "1", label: "Ecology", meaning: "We protect water.", signals: "Litter = violation." }, + ], + methodSelections: { + communication: [ + { + id: "signal", + label: "Signal", + sections: { + corePrinciple: "Privacy first.", + logisticsAdmin: "Admins rotate.", + codeOfConduct: "No doxxing.", + }, + }, + ], + }, + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]); + expect(out[0].entries[0].title).toBe("Ecology"); + expect(out[0].entries[0].body).toBe( + "We protect water.\n\nLitter = violation.", + ); + expect(out[0].entries[0].blocks).toBeUndefined(); + expect(out[1].entries[0].title).toBe("Signal"); + expect(out[1].entries[0].blocks?.length).toBeGreaterThanOrEqual(3); + }); + + it("strips Overview but keeps other categories when both exist", () => { + const doc = { + sections: [ + { categoryName: "Overview", entries: [{ title: "Community", body: "x" }] }, + { categoryName: "Membership", entries: [{ title: "Open", body: "y" }] }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Membership"]); + }); + + it("prefers document.coreValues over a parallel Values section in document.sections", () => { + const doc = { + sections: [ + { + categoryName: "Values", + entries: [{ title: "From template", body: "Template body" }], + }, + ], + coreValues: [ + { label: "Should not duplicate", meaning: "x", signals: "y" }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.length).toBe(1); + expect(out[0].categoryName).toBe("Values"); + expect(out[0].entries[0].title).toBe("Should not duplicate"); + expect(out[0].entries[0].body).toBe("x\n\ny"); + }); + + it("enriches empty core value copy from presets (label match)", () => { + const doc = { + sections: [], + coreValues: [ + { chipId: "", label: "Interdependence", meaning: "", signals: "" }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out[0].entries[0].body).toMatch(/survival and success/); + }); + + it("replaces template placeholder bodies with preset copy, Values first", () => { + const placeholder = + "Suggested focus for this governance area. Replace with your own language in the create flow."; + const doc = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Slack", body: placeholder }], + }, + { + categoryName: "Values", + entries: [{ title: "Adaptability", body: placeholder }], + }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]); + expect(out[0].entries[0].body).not.toContain("Suggested focus"); + expect(out[0].entries[0].body.length).toBeGreaterThan(20); + expect(out[1].entries[0].blocks?.length).toBeGreaterThanOrEqual(1); + expect(out[1].entries[0].blocks?.[0]?.body?.length).toBeGreaterThan(10); + }); + + it("matches parseDocumentSectionsForDisplay when no coreValues or methodSelections", () => { + const doc = { + sections: [ + { categoryName: "X", entries: [{ title: "t", body: "b" }] }, + ], + }; + expect(parsePublishedDocumentForCommunityRuleDisplay(doc)).toEqual( + doc.sections, + ); + }); +});