Custom add and create flow polish
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user