Refine use cases rule examples

This commit is contained in:
adilallo
2026-05-19 22:16:08 -06:00
parent 7c46cbd87b
commit 2f2b5d0dc2
65 changed files with 3129 additions and 252 deletions
+38
View File
@@ -609,6 +609,44 @@ export async function duplicatePublishedRule(
}
}
export async function duplicateUseCaseTemplate(
slug: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/use-cases/${encodeURIComponent(slug)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteAccountResult = { ok: true } | { ok: false; error: string };
/**
+9 -1
View File
@@ -352,5 +352,13 @@ export function parseDocumentSectionsForDisplay(
if (!document || typeof document !== "object") return [];
const sections = (document as Record<string, unknown>).sections;
if (!Array.isArray(sections)) return [];
return sections.filter(isDocumentSection);
return sections
.filter(isDocumentSection)
.map((section) => ({
...section,
entries: section.entries.map((entry) => ({
...entry,
body: typeof entry.body === "string" ? entry.body : "",
})),
}));
}
+9 -1
View File
@@ -19,11 +19,19 @@ export function isDocumentEntry(x: unknown): x is CommunityRuleEntry {
if (typeof o.title !== "string" || o.title.trim().length === 0) {
return false;
}
if (typeof o.body !== "string") return false;
if (o.blocks !== undefined) {
if (!Array.isArray(o.blocks) || !o.blocks.every(isLabeledBlock)) {
return false;
}
}
const blocks = Array.isArray(o.blocks) ? o.blocks : [];
const hasBlocks = blocks.length > 0;
if (hasBlocks) {
if (o.body !== undefined && typeof o.body !== "string") {
return false;
}
return true;
}
if (typeof o.body !== "string") return false;
return true;
}
+21
View File
@@ -170,6 +170,27 @@ export function coreValuePresetFor(chipId: string): CoreValueDetailEntry {
};
}
/**
* Preset chip id for a core value label (`"1"` … `"n"` from
* `CoreValuesSelectScreen`), or `null` when the label is bespoke.
*/
export function resolveCoreValueChipIdFromLabel(label: string): string | null {
const t = label.trim();
if (!t) return null;
const values = (coreValuesMessages as { values?: unknown }).values;
if (!Array.isArray(values)) return null;
for (let i = 0; i < values.length; i++) {
const row = values[i];
if (typeof row === "string" && row.trim() === t) return String(i + 1);
if (!row || typeof row !== "object") continue;
const o = row as Record<string, unknown>;
if (typeof o.label === "string" && o.label.trim() === t) {
return String(i + 1);
}
}
return null;
}
/** Match `coreValues.json` row by trimmed label (custom chip id / drift fallbacks). */
export function coreValuePresetForLabel(label: string): CoreValueDetailEntry {
const t = label.trim();
@@ -0,0 +1,331 @@
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { CommunityRuleLabeledBlock } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import { parseDocumentSectionsForDisplay } from "./buildPublishPayload";
import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories";
import { resolveCoreValueChipIdFromLabel } from "./finalReviewChipPresets";
import {
communicationPresetFor,
conflictManagementPresetFor,
decisionApproachPresetFor,
membershipPresetFor,
mergeCoreValueDetailWithPresets,
} from "./finalReviewChipPresets";
import { templateCategoryToGroupKey } from "./templateReviewMapping";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
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",
};
const LABELS_BY_GROUP: Record<
Exclude<TemplateFacetGroupKey, "coreValues">,
Record<string, string>
> = {
communication: COMM_LABELS,
membership: MEM_LABELS,
decisionApproaches: DEC_LABELS,
conflictManagement: CM_LABELS,
};
function slugifyId(label: string): string {
const base = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base.length > 0 ? base : "custom-method";
}
function keyForLabel(
label: string,
labelByKey: Record<string, string>,
): string | null {
const trimmed = label.trim();
for (const [key, displayLabel] of Object.entries(labelByKey)) {
if (displayLabel === trimmed) return key;
}
return null;
}
function parseConsensusPercent(body: string): number | null {
const m = body.trim().match(/^(\d+)\s*%?$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
function sectionsRecordFromBlocks(
blocks: CommunityRuleLabeledBlock[],
labelByKey: Record<string, string>,
options?: { consensusLevelKey?: string },
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const block of blocks) {
const key = keyForLabel(block.label, labelByKey);
if (!key) continue;
const body = block.body.trim();
if (options?.consensusLevelKey === key) {
const pct = parseConsensusPercent(body);
if (pct !== null) out[key] = pct;
continue;
}
if (key === "applicableScope" || key === "selectedApplicableScope") {
const parts = body
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (parts.length > 0) {
out.selectedApplicableScope = parts;
out.applicableScope = parts;
}
continue;
}
if (body.length > 0) out[key] = body;
}
return out;
}
function presetForMethod(
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 sectionsRecordFromEntry(
entry: CommunityRuleSection["entries"][number],
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
presetId: string,
): Record<string, unknown> {
const labelByKey = LABELS_BY_GROUP[groupKey];
const consensusKey =
groupKey === "decisionApproaches" ? "consensusLevel" : undefined;
if (entry.blocks && entry.blocks.length > 0) {
const fromBlocks = sectionsRecordFromBlocks(entry.blocks, labelByKey, {
consensusLevelKey: consensusKey,
});
if (Object.keys(fromBlocks).length > 0) {
return { ...presetForMethod(groupKey, presetId), ...fromBlocks };
}
}
const body = (entry.body ?? "").trim();
if (body.length === 0) {
return presetForMethod(groupKey, presetId);
}
return { ...presetForMethod(groupKey, presetId), corePrinciple: body };
}
function coreValuesFromValuesSection(
section: CommunityRuleSection,
): Array<{ chipId: string; label: string; meaning: string; signals: string }> {
const out: Array<{
chipId: string;
label: string;
meaning: string;
signals: string;
}> = [];
for (const entry of section.entries) {
const label = entry.title.trim();
if (!label) continue;
const body = (entry.body ?? "").trim();
const parts = body.length > 0 ? body.split(/\n\n+/) : [];
const meaning = (parts[0] ?? "").trim();
const signals = parts.slice(1).join("\n\n").trim();
const merged = mergeCoreValueDetailWithPresets("", label, {
meaning,
signals,
});
const chipId =
resolveCoreValueChipIdFromLabel(label) ??
`hydrated-${label.toLowerCase()}`;
out.push({
chipId,
label,
meaning: merged.meaning,
signals: merged.signals,
});
}
return out;
}
type PublishedMethodRow = {
id: string;
label: string;
sections: Record<string, unknown>;
};
function methodSelectionsFromDisplaySections(
sections: CommunityRuleSection[],
): PublishedMethodSelections {
const out: PublishedMethodSelections = {};
const pushGroup = (
key: keyof PublishedMethodSelections,
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
section: CommunityRuleSection,
) => {
const rows: PublishedMethodRow[] = [];
for (const entry of section.entries) {
const label = entry.title.trim();
if (!label) continue;
const id =
resolveMethodPresetIdFromLabel(label, groupKey) ??
`custom-${slugifyId(label)}`;
rows.push({
id,
label,
sections: sectionsRecordFromEntry(entry, groupKey, id),
});
}
if (rows.length > 0) {
switch (key) {
case "communication":
out.communication = rows as NonNullable<
PublishedMethodSelections["communication"]
>;
break;
case "membership":
out.membership = rows as NonNullable<
PublishedMethodSelections["membership"]
>;
break;
case "decisionApproaches":
out.decisionApproaches = rows as NonNullable<
PublishedMethodSelections["decisionApproaches"]
>;
break;
case "conflictManagement":
out.conflictManagement = rows as NonNullable<
PublishedMethodSelections["conflictManagement"]
>;
break;
default:
break;
}
}
};
for (const section of sections) {
const groupKey = templateCategoryToGroupKey(section.categoryName);
if (!groupKey || groupKey === "coreValues") continue;
switch (groupKey) {
case "communication":
pushGroup("communication", groupKey, section);
break;
case "membership":
pushGroup("membership", groupKey, section);
break;
case "decisionApproaches":
pushGroup("decisionApproaches", groupKey, section);
break;
case "conflictManagement":
pushGroup("conflictManagement", groupKey, section);
break;
default:
break;
}
}
return out;
}
function hasMethodSelections(ms: PublishedMethodSelections): boolean {
return Boolean(
ms.communication?.length ||
ms.membership?.length ||
ms.decisionApproaches?.length ||
ms.conflictManagement?.length,
);
}
/**
* Ensures a stored published `document` includes `methodSelections` and
* `coreValues` derived from display `sections` when missing (e.g. use-case
* template duplicates). Idempotent when the document is already normalized.
*/
export function normalizePublishedDocumentForEdit(
document: unknown,
): Record<string, unknown> {
if (!document || typeof document !== "object" || Array.isArray(document)) {
return {};
}
const doc = { ...(document as Record<string, unknown>) };
const sections = parseDocumentSectionsForDisplay(doc);
const existingMs = doc.methodSelections;
const hasMs =
existingMs &&
typeof existingMs === "object" &&
!Array.isArray(existingMs) &&
hasMethodSelections(existingMs as PublishedMethodSelections);
const existingCv = doc.coreValues;
const hasCv = Array.isArray(existingCv) && existingCv.length > 0;
if (!hasCv) {
const valuesSection = sections.find(
(s) =>
s.categoryName === RULE_SECTION_CATEGORY.values ||
templateCategoryToGroupKey(s.categoryName) === "coreValues",
);
if (valuesSection) {
const coreValues = coreValuesFromValuesSection(valuesSection);
if (coreValues.length > 0) {
doc.coreValues = coreValues;
}
}
}
if (!hasMs && sections.length > 0) {
const methodSelections = methodSelectionsFromDisplaySections(sections);
if (hasMethodSelections(methodSelections)) {
doc.methodSelections = methodSelections;
}
}
if (!Array.isArray(doc.sections) || doc.sections.length === 0) {
doc.sections = sections;
}
return doc;
}
@@ -8,6 +8,7 @@ import {
} from "./customRuleFacets";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { StoredLastPublishedRule } from "./lastPublishedRule";
import { normalizePublishedDocumentForEdit } from "./normalizePublishedDocumentForEdit";
import { methodLabelFor } from "./finalReviewChipPresets";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
@@ -120,7 +121,9 @@ export function methodSectionsPinsFromPublishedHydratePatch(
export function createFlowStateFromPublishedRule(
rule: StoredLastPublishedRule,
): Partial<CreateFlowState> {
const doc = rule.document;
const doc = normalizePublishedDocumentForEdit(
rule.document,
) as StoredLastPublishedRule["document"];
const out: Partial<CreateFlowState> = {
title: rule.title,
editingPublishedRuleId: rule.id,
+15
View File
@@ -0,0 +1,15 @@
/**
* Routes that render product chrome only (`CreateFlowTopNav`), not marketing `Top`.
* Keep in sync with `ConditionalNavigationClient`.
*/
export function isChromelessNavigationPath(
pathname: string | null | undefined,
): boolean {
if (!pathname) {
return false;
}
if (pathname.startsWith("/create") || pathname === "/login") {
return true;
}
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
}
+52
View File
@@ -0,0 +1,52 @@
import type { CommunityRuleSection } from "../app/components/type/CommunityRule/CommunityRule.types";
import { parsePublishedDocumentForCommunityRuleDisplay } from "./create/publishedDocumentToDisplaySections";
import type useCasesCompletedRules from "../messages/en/pages/useCasesCompletedRules.json";
import {
isUseCaseDetailSlug,
useCaseContentKeyForSlug,
type UseCaseDetailSlug,
} from "./useCaseSyntheticPost";
export type UseCasesCompletedRulesMessages = typeof useCasesCompletedRules;
export type UseCaseCompletedRuleFixture =
UseCasesCompletedRulesMessages[keyof UseCasesCompletedRulesMessages];
export function getUseCaseCompletedRuleFixture(
slug: UseCaseDetailSlug,
completedRules: UseCasesCompletedRulesMessages,
): UseCaseCompletedRuleFixture {
const contentKey = useCaseContentKeyForSlug(slug);
return completedRules[contentKey];
}
export function buildUseCaseCompletedRuleSections(
fixture: UseCaseCompletedRuleFixture,
): CommunityRuleSection[] {
return parsePublishedDocumentForCommunityRuleDisplay(fixture.document);
}
export function resolveUseCaseCompletedRule(
slug: string,
completedRules: UseCasesCompletedRulesMessages,
):
| {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
}
| null {
if (!isUseCaseDetailSlug(slug)) {
return null;
}
const fixture = getUseCaseCompletedRuleFixture(slug, completedRules);
const sections = buildUseCaseCompletedRuleSections(fixture);
if (sections.length === 0) {
return null;
}
return {
slug,
fixture,
sections,
};
}
+7
View File
@@ -0,0 +1,7 @@
/** Title for a rule duplicated from a use-case completed demo (profile list). */
export function useCaseTemplateDuplicateTitle(sourceTitle: string): string {
const trimmed = sourceTitle.trim();
return trimmed.length > 0
? `${trimmed} Template (Copy)`
: "Community Rule Template (Copy)";
}