Files
community-rule/lib/create/finalReviewChipPresets.ts
T
adilalloandCursor 20ea4a2f30 Give MultiSelect chips accessible names and start creation fields empty.
Catalog chips now announce Select or Deselect so they are named for assistive tech. Method and value modals open blank; Generate draft inserts the catalog copy that used to be prefilled, and Customize still keeps the section headings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 15:59:00 -06:00

300 lines
9.7 KiB
TypeScript

import communicationMessages from "../../messages/en/create/customRule/communication.json";
import conflictManagementMessages from "../../messages/en/create/customRule/conflictManagement.json";
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
import membershipMessages from "../../messages/en/create/customRule/membership.json";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CoreValueDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
/**
* Catalog copy for method/value sections. Generate draft inserts these into
* empty creation-modal fields. Publish still merges them onto saved overrides
* so every method carries a complete section payload when the author only
* edited a subset of fields.
*/
type CustomRuleMethodRow = {
id: string;
label: string;
supportText?: string;
sections?: Record<string, unknown>;
};
function readMethodsArray(source: unknown): CustomRuleMethodRow[] {
if (!source || typeof source !== "object") return [];
const methods = (source as { methods?: unknown }).methods;
if (!Array.isArray(methods)) return [];
const out: CustomRuleMethodRow[] = [];
for (const raw of methods) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
if (typeof o.id !== "string" || typeof o.label !== "string") continue;
out.push({
id: o.id,
label: o.label,
supportText:
typeof o.supportText === "string" ? o.supportText : undefined,
sections:
o.sections && typeof o.sections === "object"
? (o.sections as Record<string, unknown>)
: undefined,
});
}
return out;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
for (const v of value) {
if (typeof v === "string") out.push(v);
}
return out;
}
function asNumberClamped(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
if (value < min) return min;
if (value > max) return max;
return Math.round(value);
}
function findMethod(
source: unknown,
id: string,
): CustomRuleMethodRow | null {
const rows = readMethodsArray(source);
return rows.find((r) => r.id === id) ?? null;
}
/** Preset-default communication sections for a given method id. */
export function communicationPresetFor(
id: string,
): CommunicationMethodDetailEntry {
const method = findMethod(communicationMessages, id);
const s = method?.sections ?? {};
return {
corePrinciple: asString(s.corePrinciple),
logisticsAdmin: asString(s.logisticsAdmin),
codeOfConduct: asString(s.codeOfConduct),
};
}
export function emptyCommunicationDetails(): CommunicationMethodDetailEntry {
return { corePrinciple: "", logisticsAdmin: "", codeOfConduct: "" };
}
export function membershipPresetFor(id: string): MembershipMethodDetailEntry {
const method = findMethod(membershipMessages, id);
const s = method?.sections ?? {};
return {
eligibility: asString(s.eligibility),
joiningProcess: asString(s.joiningProcess),
expectations: asString(s.expectations),
};
}
export function emptyMembershipDetails(): MembershipMethodDetailEntry {
return { eligibility: "", joiningProcess: "", expectations: "" };
}
/** Default consensus level used when a **catalog** preset omits a value. */
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
export function decisionApproachPresetFor(
id: string,
): DecisionApproachDetailEntry {
const method = findMethod(decisionApproachesMessages, id);
const s = method?.sections ?? {};
const entry: DecisionApproachDetailEntry = {
corePrinciple: asString(s.corePrinciple),
applicableScope: asStringArray(s.applicableScope),
selectedApplicableScope: [],
stepByStepInstructions: asString(s.stepByStepInstructions),
objectionsDeadlocks: asString(s.objectionsDeadlocks),
};
if (method) {
entry.consensusLevel = asNumberClamped(
s.consensusLevel,
0,
100,
DECISION_CONSENSUS_LEVEL_DEFAULT,
);
}
return entry;
}
/** Catalog scopes/consensus kept; text fields start empty for Generate draft. */
export function emptyDecisionApproachTextFields(
preset: DecisionApproachDetailEntry,
): DecisionApproachDetailEntry {
return {
...preset,
corePrinciple: "",
stepByStepInstructions: "",
objectionsDeadlocks: "",
applicableScope: [...preset.applicableScope],
selectedApplicableScope: [...preset.selectedApplicableScope],
};
}
export function conflictManagementPresetFor(
id: string,
): ConflictManagementDetailEntry {
const method = findMethod(conflictManagementMessages, id);
const s = method?.sections ?? {};
return {
corePrinciple: asString(s.corePrinciple),
applicableScope: asStringArray(s.applicableScope),
selectedApplicableScope: [],
processProtocol: asString(s.processProtocol),
restorationFallbacks: asString(s.restorationFallbacks),
};
}
export function emptyConflictManagementDetails(): ConflictManagementDetailEntry {
return {
corePrinciple: "",
applicableScope: [],
selectedApplicableScope: [],
processProtocol: "",
restorationFallbacks: "",
};
}
/**
* Preset meaning/signals for a core value chip. Used by Generate draft and
* by publish/display merge when the author has not saved an override.
*
* Lookup order:
* 1. Numeric chip id → 1-based index into `coreValues.json` `values[]`
* (matches `CoreValuesSelectScreen` chip ids).
* 2. Otherwise → empty (bespoke / template-derived chip with no preset).
*/
export function coreValuePresetFor(chipId: string): CoreValueDetailEntry {
const values = (coreValuesMessages as { values?: unknown }).values;
if (!Array.isArray(values)) return { meaning: "", signals: "" };
const idx = Number.parseInt(chipId, 10);
if (!Number.isInteger(idx) || idx < 1 || idx > values.length) {
return { meaning: "", signals: "" };
}
const row = values[idx - 1];
if (!row || typeof row !== "object") return { meaning: "", signals: "" };
const o = row as Record<string, unknown>;
return {
meaning: asString(o.meaning),
signals: asString(o.signals),
};
}
/**
* 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();
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). */
export function methodLabelFor(
groupKey: TemplateFacetGroupKey,
id: string,
): string {
const source =
groupKey === "communication"
? communicationMessages
: groupKey === "membership"
? membershipMessages
: groupKey === "decisionApproaches"
? decisionApproachesMessages
: groupKey === "conflictManagement"
? conflictManagementMessages
: null;
if (!source) return "";
const method = findMethod(source, id);
return method?.label ?? "";
}
/** Label for publish / review: preset JSON row, else user-authored wizard meta. */
export function publishedMethodDisplayLabel(
groupKey: TemplateFacetGroupKey,
id: string,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): string {
const preset = methodLabelFor(groupKey, id);
if (preset.length > 0) return preset;
return customMeta?.[id]?.label?.trim() ?? "";
}