Final review edit modals created
This commit is contained in:
@@ -76,6 +76,36 @@ function buildCoreValuePrefill(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of {@link buildTemplateCustomizePrefill} that pulls *only* the
|
||||
* Values section out of a template body. Used by the "Use without changes"
|
||||
* handler so the verbatim template flow still seeds
|
||||
* `coreValuesChipsSnapshot` + `selectedCoreValueIds` — without that, the
|
||||
* final-review screen has no per-chip ids to attach edits to and falls
|
||||
* back to the read-only chip modal for values.
|
||||
*
|
||||
* Returns an empty object when the body is malformed or has no Values
|
||||
* section.
|
||||
*/
|
||||
export function buildCoreValuesPrefillFromTemplateBody(
|
||||
body: unknown,
|
||||
): Partial<CreateFlowState> {
|
||||
if (!body || typeof body !== "object") return {};
|
||||
const sections = (body as { sections?: unknown }).sections;
|
||||
if (!Array.isArray(sections)) return {};
|
||||
|
||||
for (const raw of sections) {
|
||||
if (!isTemplateSection(raw)) continue;
|
||||
const key = normaliseCategoryKey(raw.categoryName as string);
|
||||
if (key !== "values" && key !== "corevalues") continue;
|
||||
const titles = entryTitles(raw.entries);
|
||||
if (titles.length === 0) continue;
|
||||
return buildCoreValuePrefill(titles);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a curated template `body` (DB shape — `sections[]` with `categoryName`
|
||||
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
buildCoreValuesForDocument,
|
||||
parseSectionsFromCreateFlowState,
|
||||
} from "./buildPublishPayload";
|
||||
import {
|
||||
templateCategoryToGroupKey,
|
||||
type TemplateFacetGroupKey,
|
||||
} from "./templateReviewMapping";
|
||||
|
||||
/**
|
||||
* Chip row shape shared with `messages/en/create/reviewAndComplete/finalReview.json`
|
||||
@@ -15,6 +19,31 @@ import {
|
||||
*/
|
||||
export type FinalReviewCategoryRow = { name: string; chips: string[] };
|
||||
|
||||
/**
|
||||
* Per-chip details needed to open an *editable* chip modal on the final-review
|
||||
* screen. `overrideKey` is the stable id we use to look up / write user edits
|
||||
* in `CreateFlowState`:
|
||||
*
|
||||
* - `coreValues` → the chip id from `coreValuesChipsSnapshot`
|
||||
* (round-trips via `coreValueDetailsByChipId`).
|
||||
* - Method groups → the preset method id from
|
||||
* `messages/en/create/customRule/{group}.json` `methods[].id`
|
||||
* (round-trips via `{group}MethodDetailsById`).
|
||||
* - Unknown / template-only chips → `null` (modal falls back to read-only).
|
||||
*/
|
||||
export type FinalReviewChipEntry = {
|
||||
label: string;
|
||||
groupKey: TemplateFacetGroupKey | null;
|
||||
overrideKey: string | null;
|
||||
};
|
||||
|
||||
/** Detailed row paired with per-chip override + group metadata. */
|
||||
export type FinalReviewCategoryRowDetailed = {
|
||||
name: string;
|
||||
groupKey: TemplateFacetGroupKey | null;
|
||||
entries: FinalReviewChipEntry[];
|
||||
};
|
||||
|
||||
/** Category labels supplied by the caller (pulled from localized messages). */
|
||||
export type FinalReviewCategoryNames = {
|
||||
values: string;
|
||||
@@ -41,24 +70,166 @@ function readMethodsArray(source: unknown): MethodPreset[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function labelsFromIds(
|
||||
/**
|
||||
* Resolve an ordered list of preset ids to `{label, id}` entries, filtering
|
||||
* missing/duplicate labels. The id is returned alongside so callers can key
|
||||
* per-chip overrides by the stable preset id (e.g. `"signal"`) even after
|
||||
* labels change through localization.
|
||||
*/
|
||||
function entriesFromIds(
|
||||
ids: readonly string[] | undefined,
|
||||
methods: readonly MethodPreset[],
|
||||
): string[] {
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): FinalReviewChipEntry[] {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const byId = new Map(methods.map((m) => [m.id, m.label] as const));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const out: FinalReviewChipEntry[] = [];
|
||||
for (const id of ids) {
|
||||
const label = byId.get(id);
|
||||
if (typeof label !== "string" || label.length === 0) continue;
|
||||
if (seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
out.push(label);
|
||||
out.push({ label, groupKey, overrideKey: id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-lookup a preset id by label for the template-sections path, where
|
||||
* chips carry entry titles rather than ids. Used to recover an overrideKey
|
||||
* for chips the user sees on a "Use without changes" template review.
|
||||
*/
|
||||
function overrideKeyForLabel(
|
||||
label: string,
|
||||
methods: readonly MethodPreset[],
|
||||
): string | null {
|
||||
const normalized = label.trim().toLowerCase();
|
||||
if (normalized.length === 0) return null;
|
||||
for (const m of methods) {
|
||||
if (m.label.trim().toLowerCase() === normalized) return m.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function methodsForGroup(
|
||||
groupKey: TemplateFacetGroupKey | null,
|
||||
): readonly MethodPreset[] {
|
||||
switch (groupKey) {
|
||||
case "communication":
|
||||
return readMethodsArray(communicationMessages);
|
||||
case "membership":
|
||||
return readMethodsArray(membershipMessages);
|
||||
case "decisionApproaches":
|
||||
return readMethodsArray(decisionApproachesMessages);
|
||||
case "conflictManagement":
|
||||
return readMethodsArray(conflictManagementMessages);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed builder: same logic as {@link buildFinalReviewCategoriesFromState}
|
||||
* but each chip is returned with its `overrideKey` + `groupKey` so the
|
||||
* final-review screen can wire chip clicks to an editable modal that
|
||||
* round-trips writes back into `CreateFlowState`.
|
||||
*/
|
||||
export function buildFinalReviewCategoryRowsDetailed(
|
||||
state: CreateFlowState,
|
||||
names: FinalReviewCategoryNames,
|
||||
): FinalReviewCategoryRowDetailed[] {
|
||||
const sections = parseSectionsFromCreateFlowState(state);
|
||||
const coreValues = buildCoreValuesForDocument(state);
|
||||
|
||||
const coreValueEntries: FinalReviewChipEntry[] = coreValues.map((r) => ({
|
||||
label: r.label,
|
||||
groupKey: "coreValues" as const,
|
||||
overrideKey: r.chipId,
|
||||
}));
|
||||
|
||||
// Use-without-changes / pre-rendered template body.
|
||||
if (sections.length > 0) {
|
||||
const rows: FinalReviewCategoryRowDetailed[] = [];
|
||||
|
||||
// Always prefer the chip-snapshot derived entries when present so the
|
||||
// values row uses stable per-chip ids the edit modal can attach to.
|
||||
// We then skip any Values section in the template body to avoid
|
||||
// duplicating the row (the snapshot already represents the same data).
|
||||
if (coreValueEntries.length > 0) {
|
||||
rows.push({
|
||||
name: names.values,
|
||||
groupKey: "coreValues",
|
||||
entries: coreValueEntries,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of sections) {
|
||||
const groupKey = templateCategoryToGroupKey(s.categoryName);
|
||||
if (groupKey === "coreValues" && coreValueEntries.length > 0) continue;
|
||||
const methods = methodsForGroup(groupKey);
|
||||
const entries: FinalReviewChipEntry[] = [];
|
||||
for (const e of s.entries) {
|
||||
const title = e.title.trim();
|
||||
if (title.length === 0) continue;
|
||||
// For the Values section inside template bodies we can't recover a
|
||||
// stable chip id (no snapshot), so override is unavailable — the
|
||||
// modal will render read-only. Method sections fall back to label
|
||||
// → preset-id resolution so matching titles stay editable.
|
||||
let overrideKey: string | null = null;
|
||||
if (groupKey && groupKey !== "coreValues") {
|
||||
overrideKey = overrideKeyForLabel(title, methods);
|
||||
}
|
||||
entries.push({ label: title, groupKey, overrideKey });
|
||||
}
|
||||
if (entries.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, groupKey, entries });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const rows: FinalReviewCategoryRowDetailed[] = [
|
||||
{ name: names.values, groupKey: "coreValues", entries: coreValueEntries },
|
||||
{
|
||||
name: names.communication,
|
||||
groupKey: "communication",
|
||||
entries: entriesFromIds(
|
||||
state.selectedCommunicationMethodIds,
|
||||
methodsForGroup("communication"),
|
||||
"communication",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.membership,
|
||||
groupKey: "membership",
|
||||
entries: entriesFromIds(
|
||||
state.selectedMembershipMethodIds,
|
||||
methodsForGroup("membership"),
|
||||
"membership",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.decisions,
|
||||
groupKey: "decisionApproaches",
|
||||
entries: entriesFromIds(
|
||||
state.selectedDecisionApproachIds,
|
||||
methodsForGroup("decisionApproaches"),
|
||||
"decisionApproaches",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.conflict,
|
||||
groupKey: "conflictManagement",
|
||||
entries: entriesFromIds(
|
||||
state.selectedConflictManagementIds,
|
||||
methodsForGroup("conflictManagement"),
|
||||
"conflictManagement",
|
||||
),
|
||||
},
|
||||
];
|
||||
return rows.filter((r) => r.entries.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the final-review RuleCard category rows from the current
|
||||
* {@link CreateFlowState}.
|
||||
@@ -81,72 +252,8 @@ export function buildFinalReviewCategoriesFromState(
|
||||
state: CreateFlowState,
|
||||
names: FinalReviewCategoryNames,
|
||||
): FinalReviewCategoryRow[] {
|
||||
const sections = parseSectionsFromCreateFlowState(state);
|
||||
const coreValueLabels = buildCoreValuesForDocument(state).map((r) => r.label);
|
||||
|
||||
// Use-without-changes / pre-rendered template body: the sections array is
|
||||
// the source of truth. Collapse each section's entries to its titles; the
|
||||
// RuleCard category UI shows only labels, not per-entry body copy.
|
||||
if (sections.length > 0) {
|
||||
const rows: FinalReviewCategoryRow[] = [];
|
||||
|
||||
// If core values were also captured (e.g., the template surfaced both),
|
||||
// keep them up top for visual parity with the custom-rule flow. Otherwise
|
||||
// any `Values` section already inside `sections` covers the same ground.
|
||||
if (coreValueLabels.length > 0) {
|
||||
const hasValuesSection = sections.some(
|
||||
(s) => s.categoryName.toLowerCase() === names.values.toLowerCase(),
|
||||
);
|
||||
if (!hasValuesSection) {
|
||||
rows.push({ name: names.values, chips: coreValueLabels });
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of sections) {
|
||||
const chips = s.entries
|
||||
.map((e) => e.title.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
if (chips.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, chips });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const communicationMethods = readMethodsArray(communicationMessages);
|
||||
const membershipMethods = readMethodsArray(membershipMessages);
|
||||
const decisionApproachMethods = readMethodsArray(decisionApproachesMessages);
|
||||
const conflictManagementMethods = readMethodsArray(conflictManagementMessages);
|
||||
|
||||
const rows: FinalReviewCategoryRow[] = [
|
||||
{ name: names.values, chips: coreValueLabels },
|
||||
{
|
||||
name: names.communication,
|
||||
chips: labelsFromIds(
|
||||
state.selectedCommunicationMethodIds,
|
||||
communicationMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.membership,
|
||||
chips: labelsFromIds(
|
||||
state.selectedMembershipMethodIds,
|
||||
membershipMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.decisions,
|
||||
chips: labelsFromIds(
|
||||
state.selectedDecisionApproachIds,
|
||||
decisionApproachMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.conflict,
|
||||
chips: labelsFromIds(
|
||||
state.selectedConflictManagementIds,
|
||||
conflictManagementMethods,
|
||||
),
|
||||
},
|
||||
];
|
||||
return rows.filter((r) => r.chips.length > 0);
|
||||
return buildFinalReviewCategoryRowsDetailed(state, names).map((r) => ({
|
||||
name: r.name,
|
||||
chips: r.entries.map((e) => e.label),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { CoreValueDetailEntry, CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
CoreValueDetailEntry,
|
||||
CreateFlowState,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../../app/(app)/create/types";
|
||||
import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
decisionApproachPresetFor,
|
||||
membershipPresetFor,
|
||||
methodLabelFor,
|
||||
} from "./finalReviewChipPresets";
|
||||
|
||||
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
@@ -52,6 +66,36 @@ export function buildCoreValuesForDocument(state: CreateFlowState): Array<{
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured per-group method selections emitted into `document.methodSelections`
|
||||
* at publish time. Each entry carries the preset id (stable key), display
|
||||
* label, and the fully-resolved section payload (override on top of preset).
|
||||
* Empty groups are omitted so downstream readers can iterate just the set
|
||||
* the author actually picked.
|
||||
*/
|
||||
export type PublishedMethodSelections = {
|
||||
communication?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: CommunicationMethodDetailEntry;
|
||||
}>;
|
||||
membership?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: MembershipMethodDetailEntry;
|
||||
}>;
|
||||
decisionApproaches?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: DecisionApproachDetailEntry;
|
||||
}>;
|
||||
conflictManagement?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: ConflictManagementDetailEntry;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BuildPublishPayloadResult =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -97,16 +141,93 @@ export function buildPublishPayload(
|
||||
}
|
||||
|
||||
const coreValues = buildCoreValuesForDocument(state);
|
||||
const methodSelections = buildMethodSelectionsForDocument(state);
|
||||
|
||||
const document: Record<string, unknown> = { sections, coreValues };
|
||||
if (hasAnyMethodSelection(methodSelections)) {
|
||||
document.methodSelections = methodSelections;
|
||||
}
|
||||
|
||||
if (summary !== undefined) {
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
summary,
|
||||
document: { sections, coreValues },
|
||||
};
|
||||
return { ok: true, title, summary, document };
|
||||
}
|
||||
return { ok: true, title, document: { sections, coreValues } };
|
||||
return { ok: true, title, document };
|
||||
}
|
||||
|
||||
function hasAnyMethodSelection(m: PublishedMethodSelections): boolean {
|
||||
return Boolean(
|
||||
m.communication?.length ||
|
||||
m.membership?.length ||
|
||||
m.decisionApproaches?.length ||
|
||||
m.conflictManagement?.length,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||
* overrides authored on the final-review screen. Preset defaults from the
|
||||
* shipped `messages/en/create/customRule/*.json` seed any sub-fields the
|
||||
* user didn't edit so consumers of `document.methodSelections` always see
|
||||
* a complete payload per method.
|
||||
*/
|
||||
export function buildMethodSelectionsForDocument(
|
||||
state: CreateFlowState,
|
||||
): PublishedMethodSelections {
|
||||
const out: PublishedMethodSelections = {};
|
||||
|
||||
const commIds = state.selectedCommunicationMethodIds ?? [];
|
||||
if (commIds.length > 0) {
|
||||
out.communication = commIds.map((id) => {
|
||||
const preset = communicationPresetFor(id);
|
||||
const override = state.communicationMethodDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("communication", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const memIds = state.selectedMembershipMethodIds ?? [];
|
||||
if (memIds.length > 0) {
|
||||
out.membership = memIds.map((id) => {
|
||||
const preset = membershipPresetFor(id);
|
||||
const override = state.membershipMethodDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("membership", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const daIds = state.selectedDecisionApproachIds ?? [];
|
||||
if (daIds.length > 0) {
|
||||
out.decisionApproaches = daIds.map((id) => {
|
||||
const preset = decisionApproachPresetFor(id);
|
||||
const override = state.decisionApproachDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("decisionApproaches", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const cmIds = state.selectedConflictManagementIds ?? [];
|
||||
if (cmIds.length > 0) {
|
||||
out.conflictManagement = cmIds.map((id) => {
|
||||
const preset = conflictManagementPresetFor(id);
|
||||
const override = state.conflictManagementDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("conflictManagement", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read `document.sections` from a stored published payload for display. */
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../../app/(app)/create/types";
|
||||
|
||||
/**
|
||||
* Per-method preset defaults shipped in `messages/en/create/customRule/*.json`.
|
||||
* Used to seed the final-review edit modal when the user has no saved
|
||||
* override yet, and to merge onto overrides when emitting the published rule
|
||||
* document so every method carries a complete section payload even if 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 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),
|
||||
};
|
||||
}
|
||||
|
||||
/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */
|
||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
export function decisionApproachPresetFor(
|
||||
id: string,
|
||||
): DecisionApproachDetailEntry {
|
||||
const method = findMethod(decisionApproachesMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
applicableScope: asStringArray(s.applicableScope),
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||
consensusLevel: asNumberClamped(
|
||||
s.consensusLevel,
|
||||
0,
|
||||
100,
|
||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||
),
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset meaning/signals for a core value chip. Mirrors `CoreValuesSelectScreen`'s
|
||||
* `getInitialTexts` so the final-review edit modal opens with the same
|
||||
* preset copy the select screen would have shown — without requiring the
|
||||
* user to have opened the select-screen modal first.
|
||||
*
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 ?? "";
|
||||
}
|
||||
@@ -25,6 +25,39 @@ const coreValueDetailEntrySchema = z.object({
|
||||
signals: z.string().max(8000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Final-review edit modal details per group, merged onto preset defaults at
|
||||
* publish time. Shapes mirror the custom-rule add-method modals.
|
||||
*/
|
||||
const communicationMethodDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
logisticsAdmin: z.string().max(8000),
|
||||
codeOfConduct: z.string().max(8000),
|
||||
});
|
||||
|
||||
const membershipMethodDetailEntrySchema = z.object({
|
||||
eligibility: z.string().max(8000),
|
||||
joiningProcess: z.string().max(8000),
|
||||
expectations: z.string().max(8000),
|
||||
});
|
||||
|
||||
const decisionApproachDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
stepByStepInstructions: z.string().max(8000),
|
||||
consensusLevel: z.number().int().min(0).max(100),
|
||||
objectionsDeadlocks: z.string().max(8000),
|
||||
});
|
||||
|
||||
const conflictManagementDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
processProtocol: z.string().max(8000),
|
||||
restorationFallbacks: z.string().max(8000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||
*/
|
||||
@@ -67,6 +100,18 @@ export const createFlowStateSchema = z
|
||||
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
|
||||
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
|
||||
communicationMethodDetailsById: z
|
||||
.record(communicationMethodDetailEntrySchema)
|
||||
.optional(),
|
||||
membershipMethodDetailsById: z
|
||||
.record(membershipMethodDetailEntrySchema)
|
||||
.optional(),
|
||||
decisionApproachDetailsById: z
|
||||
.record(decisionApproachDetailEntrySchema)
|
||||
.optional(),
|
||||
conflictManagementDetailsById: z
|
||||
.record(conflictManagementDetailEntrySchema)
|
||||
.optional(),
|
||||
pendingTemplateAction: z
|
||||
.object({
|
||||
slug: z.string().max(200),
|
||||
|
||||
Reference in New Issue
Block a user