Custom add and create flow polish

This commit is contained in:
adilallo
2026-05-08 20:32:24 -06:00
parent 26bcd61ea3
commit 026a1e6d71
68 changed files with 6208 additions and 527 deletions
@@ -30,12 +30,44 @@ export function applyFinalReviewChipEditPatch(
current && typeof current === "object"
? (current as Record<string, unknown>)
: {};
const snapshotLabelPatch =
patch.groupKey === "coreValues" &&
"chipLabel" in patch &&
typeof patch.chipLabel === "string"
? (() => {
const trim = patch.chipLabel.trim();
if (trim.length === 0) {
return {} as Partial<CreateFlowState>;
}
const snap = [...(state.coreValuesChipsSnapshot ?? [])];
const i = snap.findIndex((r) => r.id === patch.overrideKey);
if (i < 0) {
return {} as Partial<CreateFlowState>;
}
snap[i] = { ...snap[i], label: trim };
return {
coreValuesChipsSnapshot: snap,
} satisfies Partial<CreateFlowState>;
})()
: ({} as Partial<CreateFlowState>);
const detailPatch: Partial<CreateFlowState> = {
[stateKey]: {
...record,
[patch.overrideKey]: patch.value,
},
...snapshotLabelPatch,
};
const metaFromPatch =
patch.groupKey !== "coreValues" &&
"methodCardMeta" in patch &&
patch.methodCardMeta !== undefined
? {
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[patch.overrideKey]: patch.methodCardMeta,
} satisfies NonNullable<CreateFlowState["customMethodCardMetaById"]>,
}
: {};
if (
patch.groupKey !== "coreValues" &&
"customMethodCardFieldBlocks" in patch &&
@@ -43,11 +75,15 @@ export function applyFinalReviewChipEditPatch(
) {
return {
...detailPatch,
...metaFromPatch,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[patch.overrideKey]: patch.customMethodCardFieldBlocks,
},
};
}
if (Object.keys(metaFromPatch).length > 0) {
return { ...detailPatch, ...metaFromPatch };
}
return detailPatch;
}
+68 -15
View File
@@ -109,6 +109,33 @@ function methodsForGroup(
return readMethodPresetsForFacetGroup(groupKey);
}
function selectedMethodIdsForGroup(
state: CreateFlowState,
groupKey: TemplateFacetGroupKey,
): string[] | undefined {
switch (groupKey) {
case "communication":
return state.selectedCommunicationMethodIds;
case "membership":
return state.selectedMembershipMethodIds;
case "decisionApproaches":
return state.selectedDecisionApproachIds;
case "conflictManagement":
return state.selectedConflictManagementIds;
default:
return undefined;
}
}
/** Mirrors {@link buildPublishPayload}'s `pickMethodIds` — state wins when set. */
function pickMethodIdsForReview(
fromState: string[] | undefined,
derived: readonly string[],
): string[] {
if (fromState && fromState.length > 0) return [...fromState];
return [...derived];
}
/**
* Resolve a preset method id from a chip label (template sections / display
* enrichment where entries carry titles but not stable ids).
@@ -161,18 +188,44 @@ export function buildFinalReviewCategoryRowsDetailed(
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);
if (groupKey && groupKey !== "coreValues") {
const stateSel = selectedMethodIdsForGroup(state, groupKey);
const derivedIds: string[] = [];
for (const e of s.entries) {
const title = typeof e.title === "string" ? e.title.trim() : "";
if (title.length === 0) continue;
const id = resolveMethodPresetIdFromLabel(title, groupKey);
if (id) derivedIds.push(id);
}
// Customize flow keeps `sections` from the template but writes real
// facet picks into `selected*MethodIds` (including custom UUID cards).
// Match publish (`pickMethodIds`): when those ids exist, drive chips
// from state + `customMethodCardMetaById`, not from section titles alone.
if (stateSel && stateSel.length > 0) {
const ids = pickMethodIdsForReview(stateSel, derivedIds);
entries.push(
...entriesFromIds(
ids,
methods,
groupKey,
state.customMethodCardMetaById,
),
);
} else {
for (const e of s.entries) {
const title = typeof e.title === "string" ? e.title.trim() : "";
if (title.length === 0) continue;
const overrideKey = overrideKeyForLabel(title, methods);
entries.push({ label: title, groupKey, overrideKey });
}
}
} else {
for (const e of s.entries) {
const title = typeof e.title === "string" ? e.title.trim() : "";
if (title.length === 0) continue;
entries.push({ label: title, groupKey, overrideKey: null });
}
entries.push({ label: title, groupKey, overrideKey });
}
if (entries.length === 0) continue;
rows.push({ name: s.categoryName, groupKey, entries });
@@ -230,11 +283,11 @@ export function buildFinalReviewCategoryRowsDetailed(
* Derive the final-review Rule category rows from the current
* {@link CreateFlowState}.
*
* Two-mode contract, mirroring the two template entry points:
* Contract across template + customize paths:
* 1. **Use without changes** — `state.sections` carries the applied template
* body; we render it verbatim (`categoryName` + entry `title`s). Core
* values still come from `buildCoreValuesForDocument` when they were
* captured separately.
* body; method facets render from section titles when `selected*MethodIds`
* were cleared (see `stripCustomRuleSelectionFields`). Core values still
* come from `buildCoreValuesForDocument` when captured separately.
* 2. **Customize / plain custom-rule flow** — each Create Custom screen writes
* its selection ids into a dedicated state field. We resolve those ids
* against the curated message `methods[]` list to get the display labels,
+5 -1
View File
@@ -153,7 +153,11 @@ export function buildPublishPayload(
const methodSelections = buildMethodSelectionsForDocument(state);
if (hasAnyMethodSelection(methodSelections)) {
sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections);
sections = replaceMethodSectionsWithMethodSelections(
sections,
methodSelections,
state.customMethodCardFieldBlocksById,
);
}
const document: Record<string, unknown> = { sections, coreValues };
+92
View File
@@ -0,0 +1,92 @@
import type {
CommunityStructureChipSnapshotRow,
CreateFlowState,
} from "../../app/(app)/create/types";
import {
duplicateMethodCardTitle,
omitIdFromStringRecord,
} from "./duplicateMethodCardModalDraft";
import { moveFacetSelectionIdToFront } from "./methodCardSelectionOrder";
export const MAX_SELECTED_CORE_VALUES = 5;
/** Remove a chip from snapshot, selection ids, and per-chip detail overrides. */
export function removeCoreValueChipFromDraft(
state: CreateFlowState,
chipId: string,
): Partial<CreateFlowState> {
const snap = state.coreValuesChipsSnapshot ?? [];
const nextSnap = snap.filter((r) => r.id !== chipId);
const sel = [...(state.selectedCoreValueIds ?? [])].filter((id) => id !== chipId);
const hadDetail =
Boolean(state.coreValueDetailsByChipId) &&
Object.prototype.hasOwnProperty.call(state.coreValueDetailsByChipId, chipId);
const nextDetails = hadDetail
? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId)
: undefined;
const out: Partial<CreateFlowState> = {
coreValuesChipsSnapshot: nextSnap,
selectedCoreValueIds: sel,
};
if (hadDetail) {
out.coreValueDetailsByChipId = nextDetails;
}
return out;
}
/** Clone a core value chip with a suffixed label; returns null when at capacity. */
export function duplicateCoreValueChipInDraft(
state: CreateFlowState,
chipId: string,
duplicateTitleSuffix: string,
): {
patch: Partial<CreateFlowState>;
newId: string;
newLabel: string;
} | null {
const sel = [...(state.selectedCoreValueIds ?? [])];
if (sel.length >= MAX_SELECTED_CORE_VALUES) {
return null;
}
const snap = state.coreValuesChipsSnapshot ?? [];
const row = snap.find((r) => r.id === chipId);
if (!row) {
return null;
}
const rawLabel =
typeof row.label === "string" && row.label.trim().length > 0
? row.label.trim()
: chipId;
const newId = crypto.randomUUID();
const newLabel = duplicateMethodCardTitle(rawLabel, duplicateTitleSuffix);
const newRow: CommunityStructureChipSnapshotRow = {
id: newId,
label: newLabel,
state: "selected",
};
const nextSnap = [...snap, newRow];
const inherited = state.coreValueDetailsByChipId?.[chipId];
const nextDetails =
inherited !== undefined
? {
...(state.coreValueDetailsByChipId ?? {}),
[newId]: structuredClone(inherited),
}
: { ...(state.coreValueDetailsByChipId ?? {}) };
return {
newId,
newLabel,
patch: {
coreValuesChipsSnapshot: nextSnap,
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, newId),
...(Object.keys(nextDetails).length > 0
? { coreValueDetailsByChipId: nextDetails }
: {}),
},
};
}
@@ -0,0 +1,81 @@
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
/**
* Localized label for a duplicated method card. Supports a "%s" placeholder or
* a suffix appended to the base label (e.g. `" (copy)"`).
*/
export function duplicateMethodCardTitle(
baseLabel: string,
duplicateTitleSuffix: string,
): string {
if (duplicateTitleSuffix.includes("%s")) {
return duplicateTitleSuffix.replaceAll("%s", baseLabel);
}
return `${baseLabel}${duplicateTitleSuffix}`;
}
export function omitIdFromStringRecord<V>(
record: Record<string, V> | undefined,
id: string,
): Record<string, V> | undefined {
if (!record || !(id in record)) {
return record;
}
const next: Record<string, V> = { ...record };
delete next[id];
return Object.keys(next).length > 0 ? next : undefined;
}
/** Prefer in-modal draft, then persisted facet entry; deep-clone for state writes. */
export function cloneMethodCardDetailsForDuplicate<T>(
pendingDraft: T | null,
persisted: T | undefined,
fallback: () => T,
): T {
const base = pendingDraft ?? persisted;
if (base === undefined || base === null) {
return structuredClone(fallback());
}
return structuredClone(base);
}
export function cloneMethodCardBlocksForDuplicate(
blocksById: Record<string, CustomMethodCardFieldBlock[]> | undefined,
sourceId: string,
): CustomMethodCardFieldBlock[] {
return structuredClone(blocksById?.[sourceId] ?? []);
}
/** Shallow-copy facet maps and drop `omitId` if set (chained duplicate of staged card). */
export function forkMethodCardFacetMapsForDuplicate<TDetail>(params: {
customMethodCardMetaById:
| Record<string, { label: string; supportText: string }>
| undefined;
facetDetailsById: Record<string, TDetail> | undefined;
customMethodCardFieldBlocksById:
| Record<string, CustomMethodCardFieldBlock[]>
| undefined;
omitId: string | null;
}): {
customMethodCardMetaById: Record<string, { label: string; supportText: string }>;
facetDetailsById: Record<string, TDetail>;
customMethodCardFieldBlocksById: Record<string, CustomMethodCardFieldBlock[]>;
} {
const customMethodCardMetaById = {
...(params.customMethodCardMetaById ?? {}),
};
const facetDetailsById = { ...(params.facetDetailsById ?? {}) };
const customMethodCardFieldBlocksById = {
...(params.customMethodCardFieldBlocksById ?? {}),
};
if (params.omitId) {
delete customMethodCardMetaById[params.omitId];
delete facetDetailsById[params.omitId];
delete customMethodCardFieldBlocksById[params.omitId];
}
return {
customMethodCardMetaById,
facetDetailsById,
customMethodCardFieldBlocksById,
};
}
+3 -3
View File
@@ -1,9 +1,9 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
/**
* User-authored method cards (UUID ids) register a meta row when finalized
* from {@link CustomMethodCardWizard}. Preset rows from `methods[]` never
* appear here — keeps edit surfaces from treating custom ids like presets.
* True when `customMethodCardMetaById` has an entry for this id: wizard-finalized
* custom UUIDs, duplicate prefab clones, and **preset display overrides** after the
* user saves title/description in Customize mode (see {@link mergePresetMethodsWithCustom}).
*/
export function isCustomMethodCardId(
methodId: string,
+10 -1
View File
@@ -13,6 +13,15 @@ export function mergePresetMethodsWithCustom<
meta: Record<string, { label: string; supportText: string }> | undefined,
): T[] {
const presetIds = new Set(presets.map((p) => p.id));
const presetRows = presets.map((p) => {
const row = meta?.[p.id];
if (!row) return p;
return {
...p,
label: row.label,
supportText: row.supportText,
} as T;
});
const customRows: T[] = [];
const seenCustom = new Set<string>();
@@ -28,5 +37,5 @@ export function mergePresetMethodsWithCustom<
} as T);
}
return [...presets, ...customRows];
return [...presetRows, ...customRows];
}
@@ -0,0 +1,19 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import type { MethodCardHeaderDraft } from "./methodCardCustomizeSession";
/**
* Merges edited customize header strings into persisted method-card meta.
*/
export function methodCardMetaWithCustomizeHeader(
existing: CreateFlowState["customMethodCardMetaById"],
pendingCardId: string,
header: MethodCardHeaderDraft,
): NonNullable<CreateFlowState["customMethodCardMetaById"]> {
return {
...(existing ?? {}),
[pendingCardId]: {
label: header.title,
supportText: header.description,
},
};
}
+80
View File
@@ -0,0 +1,80 @@
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
export type MethodCardHeaderDraft = {
title: string;
description: string;
};
/** Snapshot of modal-local edits taken when the user enters Customize mode. */
export type MethodCardCustomizeSnapshot<TDraft> = {
pendingDraft: TDraft;
fieldBlocks: CustomMethodCardFieldBlock[] | null;
headerDraft: MethodCardHeaderDraft;
};
export function captureMethodCardCustomizeSnapshot<TDraft>(
pendingDraft: TDraft,
fieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft,
): MethodCardCustomizeSnapshot<TDraft> {
return {
pendingDraft: structuredClone(pendingDraft),
fieldBlocks:
fieldBlocks === null ? null : structuredClone(fieldBlocks),
headerDraft: { ...headerDraft },
};
}
export function isMethodCardCustomizeSessionDirty<TDraft>(
snapshot: MethodCardCustomizeSnapshot<TDraft>,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
): boolean {
if (!pendingDraft) {
return false;
}
if (
JSON.stringify(pendingDraft) !== JSON.stringify(snapshot.pendingDraft)
) {
return true;
}
if (headerDraft !== null) {
if (
headerDraft.title !== snapshot.headerDraft.title ||
headerDraft.description !== snapshot.headerDraft.description
) {
return true;
}
}
const cur =
draftFieldBlocks === null ? null : JSON.stringify(draftFieldBlocks);
const snap =
snapshot.fieldBlocks === null ? null : JSON.stringify(snapshot.fieldBlocks);
return cur !== snap;
}
/** For Close / overlay / Escape — skip closing when user cancels the confirm. */
export function confirmDiscardMethodCardCustomizeSession<TDraft>(
modalEditUnlocked: boolean,
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
message: string,
): boolean {
if (!modalEditUnlocked || snapshot === null) {
return true;
}
if (
!isMethodCardCustomizeSessionDirty(
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
)
) {
return true;
}
return window.confirm(message);
}
@@ -0,0 +1,75 @@
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
import {
communicationPresetFor,
conflictManagementPresetFor,
decisionApproachPresetFor,
membershipPresetFor,
} from "./finalReviewChipPresets";
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
/** True when communication facet text matches {@link communicationPresetFor} for this card id. */
export function communicationMethodFacetMatchesPreset(
details: CommunicationMethodDetailEntry | undefined,
cardId: string,
): boolean {
if (!details) return true;
const p = communicationPresetFor(cardId);
return (
details.corePrinciple === p.corePrinciple &&
details.logisticsAdmin === p.logisticsAdmin &&
details.codeOfConduct === p.codeOfConduct
);
}
export function membershipMethodFacetMatchesPreset(
details: MembershipMethodDetailEntry | undefined,
cardId: string,
): boolean {
if (!details) return true;
const p = membershipPresetFor(cardId);
return (
details.eligibility === p.eligibility &&
details.joiningProcess === p.joiningProcess &&
details.expectations === p.expectations
);
}
export function decisionApproachFacetMatchesPreset(
details: DecisionApproachDetailEntry | undefined,
cardId: string,
): boolean {
if (!details) return true;
const p = decisionApproachPresetFor(cardId);
return (
details.corePrinciple === p.corePrinciple &&
stringArraysEqual(details.applicableScope, p.applicableScope) &&
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) &&
details.stepByStepInstructions === p.stepByStepInstructions &&
details.consensusLevel === p.consensusLevel &&
details.objectionsDeadlocks === p.objectionsDeadlocks
);
}
export function conflictManagementFacetMatchesPreset(
details: ConflictManagementDetailEntry | undefined,
cardId: string,
): boolean {
if (!details) return true;
const p = conflictManagementPresetFor(cardId);
return (
details.corePrinciple === p.corePrinciple &&
stringArraysEqual(details.applicableScope, p.applicableScope) &&
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) &&
details.processProtocol === p.processProtocol &&
details.restorationFallbacks === p.restorationFallbacks
);
}
@@ -8,6 +8,38 @@ import {
} from "./customRuleFacets";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { StoredLastPublishedRule } from "./lastPublishedRule";
import { methodLabelFor } from "./finalReviewChipPresets";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
function customMethodCardMetaFromPublishedSelections(
ms: PublishedMethodSelections,
): CreateFlowState["customMethodCardMetaById"] | undefined {
const meta: NonNullable<CreateFlowState["customMethodCardMetaById"]> = {};
const absorb = (
groupKey: TemplateFacetGroupKey,
rows:
| Array<{
id: string;
label: string;
}>
| undefined,
) => {
if (!rows) return;
for (const row of rows) {
const id = typeof row.id === "string" ? row.id.trim() : "";
if (!id) continue;
if (methodLabelFor(groupKey, id).length > 0) continue;
const label = typeof row.label === "string" ? row.label.trim() : "";
if (!label) continue;
meta[id] = { label, supportText: "" };
}
};
absorb("communication", ms.communication);
absorb("membership", ms.membership);
absorb("decisionApproaches", ms.decisionApproaches);
absorb("conflictManagement", ms.conflictManagement);
return Object.keys(meta).length > 0 ? meta : undefined;
}
/**
* True when `patch` (from {@link createFlowStateFromPublishedRule}) expects
@@ -31,6 +63,26 @@ export function isPublishedRuleSelectionMissing(
return false;
}
/**
* True when published-rule hydration should run (or continue) — facet ids still
* empty, or {@link createFlowStateFromPublishedRule} produced
* `customMethodCardMetaById` for user-authored method UUIDs that `state` does
* not have yet (final-review chips use meta + id when no preset label exists).
*/
export function isPublishedRuleHydratePatchIncomplete(
state: CreateFlowState,
patch: Partial<CreateFlowState>,
): boolean {
if (isPublishedRuleSelectionMissing(state, patch)) return true;
const pm = patch.customMethodCardMetaById;
if (!pm || Object.keys(pm).length === 0) return false;
const sm = state.customMethodCardMetaById ?? {};
for (const key of Object.keys(pm)) {
if (!sm[key]) return true;
}
return false;
}
/**
* Pin flags for method-card facets: persisted for hydration and footer Confirm.
* Card Stack display pulls selections to the top whenever `selected*` ids are
@@ -171,6 +223,11 @@ export function createFlowStateFromPublishedRule(
);
}
const customMeta = customMethodCardMetaFromPublishedSelections(ms);
if (customMeta) {
out.customMethodCardMetaById = customMeta;
}
/** Drop template `sections` so final-review uses `methodSelections` / selected ids (edit path). */
out.sections = [];
return out;
@@ -3,6 +3,7 @@ import type {
CommunityRuleSection,
} from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import {
PUBLISH_FALLBACK_OVERVIEW_CATEGORY,
parseDocumentSectionsForDisplay,
@@ -221,6 +222,14 @@ function parseMethodSelectionsLoose(
return ms as PublishedMethodSelections;
}
function parseCustomFieldBlocksByIdLoose(
document: Record<string, unknown>,
): Record<string, CustomMethodCardFieldBlock[]> | undefined {
const raw = document.customMethodCardFieldBlocksById;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
return raw as Record<string, CustomMethodCardFieldBlock[]>;
}
/**
* Full `CommunityRule` sections for a published `document` JSON blob: validated
* `document.sections` plus synthesized categories from `document.coreValues` and
@@ -253,30 +262,49 @@ export function parsePublishedDocumentForCommunityRuleDisplay(
seen.add(valuesSection.categoryName);
}
let displaySections = [...base, ...extra];
const methodSelections = parseMethodSelectionsLoose(doc);
const customFieldBlocksById = parseCustomFieldBlocksByIdLoose(doc);
if (methodSelections) {
const comm = sectionFromCommunication(methodSelections.communication ?? []);
if (comm && !seen.has(comm.categoryName)) {
extra.push(comm);
seen.add(comm.categoryName);
}
const mem = sectionFromMembership(methodSelections.membership ?? []);
if (mem && !seen.has(mem.categoryName)) {
extra.push(mem);
seen.add(mem.categoryName);
}
const dec = sectionFromDecision(methodSelections.decisionApproaches ?? []);
if (dec && !seen.has(dec.categoryName)) {
extra.push(dec);
seen.add(dec.categoryName);
}
const cm = sectionFromConflict(methodSelections.conflictManagement ?? []);
if (cm && !seen.has(cm.categoryName)) {
extra.push(cm);
seen.add(cm.categoryName);
}
/**
* `document.sections` can lag `document.methodSelections` (e.g. API responses
* or older rows). Do not skip merging when the category already exists —
* that hid user-authored method cards on `/create/completed`.
*/
const replaceCategory = (fresh: CommunityRuleSection | null) => {
if (!fresh) return;
displaySections = displaySections.filter(
(s) => s.categoryName !== fresh.categoryName,
);
displaySections.push(fresh);
};
replaceCategory(
sectionFromCommunication(
methodSelections.communication ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromMembership(
methodSelections.membership ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromDecision(
methodSelections.decisionApproaches ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromConflict(
methodSelections.conflictManagement ?? [],
customFieldBlocksById,
),
);
}
const combined = [...base, ...extra].map(enrichDisplaySection);
const combined = displaySections.map(enrichDisplaySection);
return sortSectionsCanonical(combined);
}
+80 -9
View File
@@ -4,8 +4,56 @@ import type {
CommunityRuleSection,
} from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { templateCategoryToGroupKey } from "./templateReviewMapping";
/**
* Serialize wizard-authored field blocks into Community Rule labeled rows for
* read-only surfaces (completed step, exported views). Matches how those blocks
* are edited in-app; `placeholderText` holds the author's answer for text blocks.
*/
export function labeledBlocksFromCustomMethodCardFieldBlocks(
blocks: CustomMethodCardFieldBlock[],
): CommunityRuleLabeledBlock[] {
const out: CommunityRuleLabeledBlock[] = [];
for (const b of blocks) {
switch (b.kind) {
case "text": {
const body = nonEmptyTrimmed(b.placeholderText);
if (body) out.push({ label: b.blockTitle, body });
break;
}
case "badges": {
const opts = b.options.filter((x) => typeof x === "string" && x.trim().length > 0);
if (opts.length === 0) break;
out.push({ label: b.blockTitle, body: opts.join(", ") });
break;
}
case "upload": {
const name = nonEmptyTrimmed(b.fileName);
const url = nonEmptyTrimmed(b.assetUrl);
const body = name ?? url;
if (body) out.push({ label: b.blockTitle, body });
break;
}
case "proportion":
out.push({
label: b.blockTitle,
body: `${b.defaultPercent}%`,
});
break;
default:
break;
}
}
return out;
}
export type CommunityRuleEntryFromChipOptions = {
consensusLevelKey?: string;
customFieldBlocks?: CustomMethodCardFieldBlock[];
};
/** Canonical `categoryName` strings for method groups in published documents. */
export const RULE_SECTION_CATEGORY = {
values: "Values",
@@ -107,21 +155,35 @@ export function communityRuleEntryFromMethodChip(
title: string,
sections: Record<string, unknown>,
labelByKey: Record<string, string>,
options?: { consensusLevelKey?: string },
options?: CommunityRuleEntryFromChipOptions,
): CommunityRuleEntry | null {
const blocks = blocksFromKeyedRecord(sections, labelByKey, options);
const presetBlocks = blocksFromKeyedRecord(
sections,
labelByKey,
options?.consensusLevelKey
? { consensusLevelKey: options.consensusLevelKey }
: undefined,
);
const wizardBlocks =
options?.customFieldBlocks && options.customFieldBlocks.length > 0
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
: [];
const blocks = [...presetBlocks, ...wizardBlocks];
if (blocks.length === 0) return null;
return { title, body: "", blocks };
}
export function sectionFromCommunication(
ms: NonNullable<PublishedMethodSelections["communication"]>,
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
): CommunityRuleSection | null {
if (ms.length === 0) return null;
const entries: CommunityRuleEntry[] = [];
for (const m of ms) {
const sec = m.sections as unknown as Record<string, unknown>;
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS);
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
customFieldBlocks: customFieldBlocksById?.[m.id],
});
if (e) entries.push(e);
}
return entries.length > 0
@@ -131,12 +193,15 @@ export function sectionFromCommunication(
export function sectionFromMembership(
ms: NonNullable<PublishedMethodSelections["membership"]>,
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
): CommunityRuleSection | null {
if (ms.length === 0) return null;
const entries: CommunityRuleEntry[] = [];
for (const m of ms) {
const sec = m.sections as unknown as Record<string, unknown>;
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS);
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
customFieldBlocks: customFieldBlocksById?.[m.id],
});
if (e) entries.push(e);
}
return entries.length > 0
@@ -146,6 +211,7 @@ export function sectionFromMembership(
export function sectionFromDecision(
ms: NonNullable<PublishedMethodSelections["decisionApproaches"]>,
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
): CommunityRuleSection | null {
if (ms.length === 0) return null;
const entries: CommunityRuleEntry[] = [];
@@ -159,6 +225,7 @@ export function sectionFromDecision(
delete merged.selectedApplicableScope;
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
consensusLevelKey: "consensusLevel",
customFieldBlocks: customFieldBlocksById?.[m.id],
});
if (e) entries.push(e);
}
@@ -169,6 +236,7 @@ export function sectionFromDecision(
export function sectionFromConflict(
ms: NonNullable<PublishedMethodSelections["conflictManagement"]>,
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
): CommunityRuleSection | null {
if (ms.length === 0) return null;
const entries: CommunityRuleEntry[] = [];
@@ -180,7 +248,9 @@ export function sectionFromConflict(
formatScopePayload(sec.applicableScope);
if (scope) merged.applicableScope = scope;
delete merged.selectedApplicableScope;
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS);
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
customFieldBlocks: customFieldBlocksById?.[m.id],
});
if (e) entries.push(e);
}
return entries.length > 0
@@ -195,20 +265,21 @@ export function sectionFromConflict(
export function replaceMethodSectionsWithMethodSelections(
sections: CommunityRuleSection[],
ms: PublishedMethodSelections,
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
): CommunityRuleSection[] {
return sections.map((s) => {
const gk = templateCategoryToGroupKey(s.categoryName);
if (gk === "communication" && ms.communication?.length) {
return sectionFromCommunication(ms.communication) ?? s;
return sectionFromCommunication(ms.communication, customFieldBlocksById) ?? s;
}
if (gk === "membership" && ms.membership?.length) {
return sectionFromMembership(ms.membership) ?? s;
return sectionFromMembership(ms.membership, customFieldBlocksById) ?? s;
}
if (gk === "decisionApproaches" && ms.decisionApproaches?.length) {
return sectionFromDecision(ms.decisionApproaches) ?? s;
return sectionFromDecision(ms.decisionApproaches, customFieldBlocksById) ?? s;
}
if (gk === "conflictManagement" && ms.conflictManagement?.length) {
return sectionFromConflict(ms.conflictManagement) ?? s;
return sectionFromConflict(ms.conflictManagement, customFieldBlocksById) ?? s;
}
return s;
});
@@ -0,0 +1,49 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { isCustomMethodCardId } from "./isCustomMethodCardId";
/**
* Create modals use {@link CustomMethodCardModalBody} when there are structured field
* blocks for this method id (wizard-finalized cards, final-review chip edits, etc.),
* including **proportion-only** layouts.
*
* Check persisted blocks **before** {@link isCustomMethodCardId}: `meta` can be absent
* while `customMethodCardFieldBlocksById[id]` is still populated (e.g. partial merges).
*
* Duplicating a preset registers `meta` for the clone but leaves blocks empty — those
* stubs keep the facet's structured edit fields until the user adds blocks (then this
* returns true once persisted blocks are non-empty).
*
* **View mode** (`modalEditUnlocked` false): when the custom card still has facet copy
* that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to
* {@link CustomMethodCardModalBody} so meta-only wizard cards show policy copy instead
* of empty preset section editors. Pass `customFacetDetailsMatchPreset: false` when the
* caller knows facet details were edited or cloned from a filled preset.
*/
export function usesWizardFieldBlocksModalBody(args: {
methodId: string;
meta: CreateFlowState["customMethodCardMetaById"];
fieldBlocksById: CreateFlowState["customMethodCardFieldBlocksById"];
modalEditUnlocked: boolean;
draftFieldBlocks: readonly CustomMethodCardFieldBlock[] | null;
/** When strictly `true` and modal is read-only, use wizard body for custom cards with empty blocks. */
customFacetDetailsMatchPreset?: boolean;
}): boolean {
const persisted = args.fieldBlocksById?.[args.methodId];
if (Array.isArray(persisted) && persisted.length > 0) {
return true;
}
if (!isCustomMethodCardId(args.methodId, args.meta)) {
return false;
}
if (
args.modalEditUnlocked &&
args.draftFieldBlocks !== null &&
args.draftFieldBlocks.length > 0
) {
return true;
}
return (
!args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true
);
}