Create flow cleanup: edit published rules, custom method cards, review polish, share/export & draft fixes #50
@@ -163,16 +163,11 @@ export function useTemplateReviewActions({
|
|||||||
})
|
})
|
||||||
: sections;
|
: sections;
|
||||||
|
|
||||||
const summaryRaw =
|
|
||||||
typeof template.description === "string"
|
|
||||||
? template.description.trim()
|
|
||||||
: "";
|
|
||||||
const hasCommunityName =
|
const hasCommunityName =
|
||||||
typeof state.title === "string" && state.title.trim().length > 0;
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
updateState({
|
updateState({
|
||||||
...coreValuesPrefill,
|
...coreValuesPrefill,
|
||||||
sections: sectionsWithoutValues,
|
sections: sectionsWithoutValues,
|
||||||
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
|
||||||
templateReviewBackSlug: templateReviewSlug,
|
templateReviewBackSlug: templateReviewSlug,
|
||||||
...(hasCommunityName
|
...(hasCommunityName
|
||||||
? { pendingTemplateAction: undefined }
|
? { pendingTemplateAction: undefined }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { CommunityRuleSection } from "../../../../components/type/Community
|
|||||||
import Alert from "../../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
|
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
|
||||||
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
|
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections";
|
||||||
import {
|
import {
|
||||||
readLastPublishedRule,
|
readLastPublishedRule,
|
||||||
writeLastPublishedRule,
|
writeLastPublishedRule,
|
||||||
@@ -48,7 +48,7 @@ function initialCompletedUi(
|
|||||||
documentSections: [],
|
documentSections: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const parsed = parseDocumentSectionsForDisplay(stored.document);
|
const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
return {
|
return {
|
||||||
headerTitle: "",
|
headerTitle: "",
|
||||||
@@ -105,7 +105,7 @@ export function CompletedScreen() {
|
|||||||
summary: detail.rule.summary,
|
summary: detail.rule.summary,
|
||||||
document: doc,
|
document: doc,
|
||||||
});
|
});
|
||||||
const parsed = parseDocumentSectionsForDisplay(doc);
|
const parsed = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
|
router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ export function FinalReviewScreen() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Match {@link CommunityReviewScreen}: the card body is the free-text
|
* Match {@link CommunityReviewScreen}: the card body is the free-text
|
||||||
* `community-context` field only — not `summary` (template / one-line
|
* `community-context` field only — not `summary`.
|
||||||
* rule summary can carry template-review copy).
|
|
||||||
*/
|
*/
|
||||||
const ruleCardDescription = useMemo(() => {
|
const ruleCardDescription = useMemo(() => {
|
||||||
const raw =
|
const raw =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
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 CommunityRule from "../../../components/type/CommunityRule";
|
||||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = parseDocumentSectionsForDisplay(rule.document);
|
const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document);
|
||||||
const description =
|
const description =
|
||||||
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
||||||
? rule.summary
|
? rule.summary
|
||||||
|
|||||||
+2
-2
@@ -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.
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
* Detailed builder: same logic as {@link buildFinalReviewCategoriesFromState}
|
||||||
* but each chip is returned with its `overrideKey` + `groupKey` so the
|
* but each chip is returned with its `overrideKey` + `groupKey` so the
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import type {
|
import type {
|
||||||
CommunicationMethodDetailEntry,
|
CommunicationMethodDetailEntry,
|
||||||
ConflictManagementDetailEntry,
|
ConflictManagementDetailEntry,
|
||||||
CoreValueDetailEntry,
|
|
||||||
CreateFlowState,
|
CreateFlowState,
|
||||||
DecisionApproachDetailEntry,
|
DecisionApproachDetailEntry,
|
||||||
MembershipMethodDetailEntry,
|
MembershipMethodDetailEntry,
|
||||||
} from "../../app/(app)/create/types";
|
} from "../../app/(app)/create/types";
|
||||||
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
|
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||||
|
import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories";
|
||||||
import {
|
import {
|
||||||
communicationPresetFor,
|
communicationPresetFor,
|
||||||
conflictManagementPresetFor,
|
conflictManagementPresetFor,
|
||||||
decisionApproachPresetFor,
|
decisionApproachPresetFor,
|
||||||
membershipPresetFor,
|
membershipPresetFor,
|
||||||
|
mergeCoreValueDetailWithPresets,
|
||||||
methodLabelFor,
|
methodLabelFor,
|
||||||
} from "./finalReviewChipPresets";
|
} from "./finalReviewChipPresets";
|
||||||
import { isDocumentEntry } from "./documentEntryGuards";
|
import { isDocumentEntry } from "./documentEntryGuards";
|
||||||
|
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
|
||||||
|
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||||
|
|
||||||
export { isDocumentEntry } from "./documentEntryGuards";
|
export { isDocumentEntry } from "./documentEntryGuards";
|
||||||
|
|
||||||
@@ -53,12 +56,12 @@ export function buildCoreValuesForDocument(state: CreateFlowState): Array<{
|
|||||||
return snap
|
return snap
|
||||||
.filter((r) => selected.has(r.id))
|
.filter((r) => selected.has(r.id))
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const d: CoreValueDetailEntry | undefined = details[r.id];
|
const merged = mergeCoreValueDetailWithPresets(r.id, r.label, details[r.id]);
|
||||||
return {
|
return {
|
||||||
chipId: r.id,
|
chipId: r.id,
|
||||||
label: r.label,
|
label: r.label,
|
||||||
meaning: d?.meaning ?? "",
|
meaning: merged.meaning,
|
||||||
signals: d?.signals ?? "",
|
signals: merged.signals,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,7 +105,9 @@ export type BuildPublishPayloadResult =
|
|||||||
}
|
}
|
||||||
| { ok: false; error: string };
|
| { 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 =
|
const DEFAULT_FALLBACK_BODY =
|
||||||
"This CommunityRule was created in the create flow. Add more detail in a future edit.";
|
"This CommunityRule was created in the create flow. Add more detail in a future edit.";
|
||||||
@@ -124,7 +129,8 @@ export function buildPublishPayload(
|
|||||||
return undefined;
|
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);
|
let sections = parseSectionsFromCreateFlowState(state);
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
@@ -138,8 +144,18 @@ export function buildPublishPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coreValues = buildCoreValuesForDocument(state);
|
const coreValues = buildCoreValuesForDocument(state);
|
||||||
|
if (coreValues.length > 0) {
|
||||||
|
sections = sections.filter(
|
||||||
|
(s) => templateCategoryToGroupKey(s.categoryName) !== "coreValues",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const methodSelections = buildMethodSelectionsForDocument(state);
|
const methodSelections = buildMethodSelectionsForDocument(state);
|
||||||
|
|
||||||
|
if (hasAnyMethodSelection(methodSelections)) {
|
||||||
|
sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections);
|
||||||
|
}
|
||||||
|
|
||||||
const document: Record<string, unknown> = { sections, coreValues };
|
const document: Record<string, unknown> = { sections, coreValues };
|
||||||
if (hasAnyMethodSelection(methodSelections)) {
|
if (hasAnyMethodSelection(methodSelections)) {
|
||||||
document.methodSelections = 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`
|
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||||
* overrides authored on the final-review screen. Preset defaults from the
|
* overrides authored on the final-review screen. Preset defaults from the
|
||||||
@@ -170,9 +239,15 @@ function hasAnyMethodSelection(m: PublishedMethodSelections): boolean {
|
|||||||
export function buildMethodSelectionsForDocument(
|
export function buildMethodSelectionsForDocument(
|
||||||
state: CreateFlowState,
|
state: CreateFlowState,
|
||||||
): PublishedMethodSelections {
|
): PublishedMethodSelections {
|
||||||
|
const derived = deriveMethodPresetIdsFromSections(
|
||||||
|
parseSectionsFromCreateFlowState(state),
|
||||||
|
);
|
||||||
const out: PublishedMethodSelections = {};
|
const out: PublishedMethodSelections = {};
|
||||||
|
|
||||||
const commIds = state.selectedCommunicationMethodIds ?? [];
|
const commIds = pickMethodIds(
|
||||||
|
state.selectedCommunicationMethodIds,
|
||||||
|
derived.communication,
|
||||||
|
);
|
||||||
if (commIds.length > 0) {
|
if (commIds.length > 0) {
|
||||||
out.communication = commIds.map((id) => {
|
out.communication = commIds.map((id) => {
|
||||||
const preset = communicationPresetFor(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) {
|
if (memIds.length > 0) {
|
||||||
out.membership = memIds.map((id) => {
|
out.membership = memIds.map((id) => {
|
||||||
const preset = membershipPresetFor(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) {
|
if (daIds.length > 0) {
|
||||||
out.decisionApproaches = daIds.map((id) => {
|
out.decisionApproaches = daIds.map((id) => {
|
||||||
const preset = decisionApproachPresetFor(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) {
|
if (cmIds.length > 0) {
|
||||||
out.conflictManagement = cmIds.map((id) => {
|
out.conflictManagement = cmIds.map((id) => {
|
||||||
const preset = conflictManagementPresetFor(id);
|
const preset = conflictManagementPresetFor(id);
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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). */
|
/** Resolve method preset label by id for a given group (localized display). */
|
||||||
export function methodLabelFor(
|
export function methodLabelFor(
|
||||||
groupKey: TemplateFacetGroupKey,
|
groupKey: TemplateFacetGroupKey,
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
corePrinciple: "Core Principle & Scope",
|
||||||
|
logisticsAdmin: "Logistics, Admin & Norms",
|
||||||
|
codeOfConduct: "Code of Conduct",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEM_LABELS: Record<string, string> = {
|
||||||
|
eligibility: "Eligibility & Philosophy",
|
||||||
|
joiningProcess: "Joining Process",
|
||||||
|
expectations: "Expectations & Removal",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEC_LABELS: Record<string, string> = {
|
||||||
|
corePrinciple: "Core Principle",
|
||||||
|
applicableScope: "Applicable Scope",
|
||||||
|
stepByStepInstructions: "Step-by-Step Instructions",
|
||||||
|
consensusLevel: "Consensus Level",
|
||||||
|
objectionsDeadlocks: "Objections & Deadlocks",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CM_LABELS: Record<string, string> = {
|
||||||
|
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<TemplateFacetGroupKey, "coreValues">,
|
||||||
|
id: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
switch (groupKey) {
|
||||||
|
case "communication":
|
||||||
|
return { ...communicationPresetFor(id) } as Record<string, unknown>;
|
||||||
|
case "membership":
|
||||||
|
return { ...membershipPresetFor(id) } as Record<string, unknown>;
|
||||||
|
case "decisionApproaches":
|
||||||
|
return { ...decisionApproachPresetFor(id) } as Record<string, unknown>;
|
||||||
|
case "conflictManagement":
|
||||||
|
return { ...conflictManagementPresetFor(id) } as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> = { ...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<string, unknown>;
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
corePrinciple: "Core Principle & Scope",
|
||||||
|
logisticsAdmin: "Logistics, Admin & Norms",
|
||||||
|
codeOfConduct: "Code of Conduct",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEM_LABELS: Record<string, string> = {
|
||||||
|
eligibility: "Eligibility & Philosophy",
|
||||||
|
joiningProcess: "Joining Process",
|
||||||
|
expectations: "Expectations & Removal",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEC_LABELS: Record<string, string> = {
|
||||||
|
corePrinciple: "Core Principle",
|
||||||
|
applicableScope: "Applicable Scope",
|
||||||
|
stepByStepInstructions: "Step-by-Step Instructions",
|
||||||
|
consensusLevel: "Consensus Level",
|
||||||
|
objectionsDeadlocks: "Objections & Deadlocks",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CM_LABELS: Record<string, string> = {
|
||||||
|
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<string, unknown>,
|
||||||
|
labelByKey: Record<string, string>,
|
||||||
|
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<string, unknown>,
|
||||||
|
labelByKey: Record<string, string>,
|
||||||
|
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<PublishedMethodSelections["communication"]>,
|
||||||
|
): CommunityRuleSection | null {
|
||||||
|
if (ms.length === 0) return null;
|
||||||
|
const entries: CommunityRuleEntry[] = [];
|
||||||
|
for (const m of ms) {
|
||||||
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
|
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<PublishedMethodSelections["membership"]>,
|
||||||
|
): CommunityRuleSection | null {
|
||||||
|
if (ms.length === 0) return null;
|
||||||
|
const entries: CommunityRuleEntry[] = [];
|
||||||
|
for (const m of ms) {
|
||||||
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
|
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<PublishedMethodSelections["decisionApproaches"]>,
|
||||||
|
): CommunityRuleSection | null {
|
||||||
|
if (ms.length === 0) return null;
|
||||||
|
const entries: CommunityRuleEntry[] = [];
|
||||||
|
for (const m of ms) {
|
||||||
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
|
const merged: Record<string, unknown> = { ...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<PublishedMethodSelections["conflictManagement"]>,
|
||||||
|
): CommunityRuleSection | null {
|
||||||
|
if (ms.length === 0) return null;
|
||||||
|
const entries: CommunityRuleEntry[] = [];
|
||||||
|
for (const m of ms) {
|
||||||
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
|
const merged: Record<string, unknown> = { ...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;
|
||||||
|
});
|
||||||
|
}
|
||||||
+2
-5
@@ -61,10 +61,7 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */
|
/** Chip titles from Template Composition.xlsx; bodies stay empty — presets hydrate at publish / display. */
|
||||||
const COMPOSITION_CHIP_BODY =
|
|
||||||
"Suggested focus for this governance area. Replace with your own language in the create flow.";
|
|
||||||
|
|
||||||
function entriesFromCompositionCell(cell: string): { title: string; body: string }[] {
|
function entriesFromCompositionCell(cell: string): { title: string; body: string }[] {
|
||||||
const trimmed = cell.trim();
|
const trimmed = cell.trim();
|
||||||
if (!trimmed) return [];
|
if (!trimmed) return [];
|
||||||
@@ -72,7 +69,7 @@ function entriesFromCompositionCell(cell: string): { title: string; body: string
|
|||||||
.split(/,\s*/)
|
.split(/,\s*/)
|
||||||
.map((title) => title.trim())
|
.map((title) => title.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((title) => ({ title, body: COMPOSITION_CHIP_BODY }));
|
.map((title) => ({ title, body: "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function bodyFromXlsxComposition(row: {
|
function bodyFromXlsxComposition(row: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
parseDocumentSectionsForDisplay,
|
parseDocumentSectionsForDisplay,
|
||||||
parseSectionsFromCreateFlowState,
|
parseSectionsFromCreateFlowState,
|
||||||
} from "../../lib/create/buildPublishPayload";
|
} from "../../lib/create/buildPublishPayload";
|
||||||
|
import { mergeCoreValueDetailWithPresets } from "../../lib/create/finalReviewChipPresets";
|
||||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||||
|
|
||||||
describe("buildPublishPayload", () => {
|
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", () => {
|
it("uses valid state.sections when present", () => {
|
||||||
const sections: CreateFlowState["sections"] = [
|
const sections: CreateFlowState["sections"] = [
|
||||||
{
|
{
|
||||||
@@ -106,9 +118,15 @@ describe("buildPublishPayload", () => {
|
|||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
|
const preset2 = mergeCoreValueDetailWithPresets("2", "Beta", undefined);
|
||||||
expect(r.document.coreValues).toEqual([
|
expect(r.document.coreValues).toEqual([
|
||||||
{ chipId: "1", label: "Alpha", meaning: "m1", signals: "s1" },
|
{ 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();
|
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", () => {
|
it("emits preset-only sections when a method is selected without an override", () => {
|
||||||
const r = buildPublishPayload({
|
const r = buildPublishPayload({
|
||||||
title: "T",
|
title: "T",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user