Refine use cases rule examples
This commit is contained in:
@@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 : "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
Reference in New Issue
Block a user