Final review edit modals created

This commit is contained in:
adilallo
2026-04-20 17:57:17 -06:00
parent c08cd62872
commit a22d53e860
27 changed files with 2410 additions and 620 deletions
+30
View File
@@ -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
+179 -72
View File
@@ -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),
}));
}
+129 -8
View File
@@ -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. */
+190
View File
@@ -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),