Fix magic-link verify URLs in email and related create-flow QA #70
@@ -1078,6 +1078,7 @@ export function FinalReviewChipEditModal({
|
|||||||
<Create
|
<Create
|
||||||
isOpen={isOpen && !addCustomWizardOpen}
|
isOpen={isOpen && !addCustomWizardOpen}
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
|
onBack={handleModalClose}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
headerContent={headerContent}
|
headerContent={headerContent}
|
||||||
showNextButton={true}
|
showNextButton={true}
|
||||||
|
|||||||
@@ -80,19 +80,21 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<IncrementerBlock
|
{value.consensusLevel !== undefined ? (
|
||||||
label={t.sectionHeadings.consensusLevel}
|
<IncrementerBlock
|
||||||
helpIcon={false}
|
label={t.sectionHeadings.consensusLevel}
|
||||||
value={value.consensusLevel}
|
helpIcon={false}
|
||||||
min={CONSENSUS_LEVEL_MIN}
|
value={value.consensusLevel}
|
||||||
max={CONSENSUS_LEVEL_MAX}
|
min={CONSENSUS_LEVEL_MIN}
|
||||||
step={CONSENSUS_LEVEL_STEP}
|
max={CONSENSUS_LEVEL_MAX}
|
||||||
onChange={(next) => patch("consensusLevel", next)}
|
step={CONSENSUS_LEVEL_STEP}
|
||||||
formatValue={(v) => `${v}%`}
|
onChange={(next) => patch("consensusLevel", next)}
|
||||||
decrementAriaLabel="Decrease consensus level"
|
formatValue={(v) => `${v}%`}
|
||||||
incrementAriaLabel="Increase consensus level"
|
decrementAriaLabel="Decrease consensus level"
|
||||||
disabled={readOnly}
|
incrementAriaLabel="Increase consensus level"
|
||||||
/>
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ModalTextAreaField
|
<ModalTextAreaField
|
||||||
label={t.sectionHeadings.objectionsDeadlocks}
|
label={t.sectionHeadings.objectionsDeadlocks}
|
||||||
value={value.objectionsDeadlocks}
|
value={value.objectionsDeadlocks}
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ export type DecisionApproachDetailEntry = {
|
|||||||
applicableScope: string[];
|
applicableScope: string[];
|
||||||
selectedApplicableScope: string[];
|
selectedApplicableScope: string[];
|
||||||
stepByStepInstructions: string;
|
stepByStepInstructions: string;
|
||||||
consensusLevel: number;
|
/**
|
||||||
|
* Catalog presets always set this. User-authored custom cards omit it until
|
||||||
|
* the author adds a proportion field or edits consensus in the facet form.
|
||||||
|
*/
|
||||||
|
consensusLevel?: number;
|
||||||
objectionsDeadlocks: string;
|
objectionsDeadlocks: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export interface CommunityRuleEntry {
|
|||||||
/** Plain text; split on blank lines into paragraphs when rendering. */
|
/** Plain text; split on blank lines into paragraphs when rendering. */
|
||||||
body: string;
|
body: string;
|
||||||
/**
|
/**
|
||||||
* When set, rendered as Figma-style label + body stacks. If non-empty, takes
|
* Figma-style label + body stacks (facet sections, wizard fields). Shown
|
||||||
* precedence over {@link body} for main content (body may be empty).
|
* after {@link body} when both are present.
|
||||||
*/
|
*/
|
||||||
blocks?: CommunityRuleLabeledBlock[];
|
blocks?: CommunityRuleLabeledBlock[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function CommunityRuleView({
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
key={entryIndex}
|
key={entryIndex}
|
||||||
title={entry.title}
|
title={entry.title}
|
||||||
body={hasBlocks ? undefined : entry.body}
|
body={entry.body}
|
||||||
rows={hasBlocks ? entry.blocks : undefined}
|
rows={hasBlocks ? entry.blocks : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ function TextBlockView({
|
|||||||
>
|
>
|
||||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
|
||||||
{hasRows
|
{hasRows
|
||||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
publishedMethodDisplayLabel,
|
publishedMethodDisplayLabel,
|
||||||
} from "./finalReviewChipPresets";
|
} from "./finalReviewChipPresets";
|
||||||
import { isDocumentEntry } from "./documentEntryGuards";
|
import { isDocumentEntry } from "./documentEntryGuards";
|
||||||
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
|
import {
|
||||||
|
replaceMethodSectionsWithMethodSelections,
|
||||||
|
withoutUnpublishedDecisionConsensus,
|
||||||
|
} from "./ruleSectionsFromMethodSelections";
|
||||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||||
|
|
||||||
export { isDocumentEntry } from "./documentEntryGuards";
|
export { isDocumentEntry } from "./documentEntryGuards";
|
||||||
@@ -78,21 +81,25 @@ export type PublishedMethodSelections = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: CommunicationMethodDetailEntry;
|
sections: CommunicationMethodDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
membership?: Array<{
|
membership?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: MembershipMethodDetailEntry;
|
sections: MembershipMethodDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
decisionApproaches?: Array<{
|
decisionApproaches?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: DecisionApproachDetailEntry;
|
sections: DecisionApproachDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
conflictManagement?: Array<{
|
conflictManagement?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: ConflictManagementDetailEntry;
|
sections: ConflictManagementDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,6 +254,14 @@ function pickMethodIds(
|
|||||||
return derived;
|
return derived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishedRowSupportText(
|
||||||
|
id: string,
|
||||||
|
meta: CreateFlowState["customMethodCardMetaById"],
|
||||||
|
): string | undefined {
|
||||||
|
const t = meta?.[id]?.supportText?.trim();
|
||||||
|
return t && t.length > 0 ? t : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||||
* overrides authored on the final-review screen. Preset defaults from the
|
* overrides authored on the final-review screen. Preset defaults from the
|
||||||
@@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.communication = commIds.map((id) => {
|
out.communication = commIds.map((id) => {
|
||||||
const preset = communicationPresetFor(id);
|
const preset = communicationPresetFor(id);
|
||||||
const override = state.communicationMethodDetailsById?.[id];
|
const override = state.communicationMethodDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.membership = memIds.map((id) => {
|
out.membership = memIds.map((id) => {
|
||||||
const preset = membershipPresetFor(id);
|
const preset = membershipPresetFor(id);
|
||||||
const override = state.membershipMethodDetailsById?.[id];
|
const override = state.membershipMethodDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.decisionApproaches = daIds.map((id) => {
|
out.decisionApproaches = daIds.map((id) => {
|
||||||
const preset = decisionApproachPresetFor(id);
|
const preset = decisionApproachPresetFor(id);
|
||||||
const override = state.decisionApproachDetailsById?.[id];
|
const override = state.decisionApproachDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
|
const merged = override ? { ...preset, ...override } : preset;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument(
|
|||||||
id,
|
id,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: withoutUnpublishedDecisionConsensus(
|
||||||
|
{ ...merged },
|
||||||
|
state.customMethodCardFieldBlocksById?.[id],
|
||||||
|
) as DecisionApproachDetailEntry,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -330,6 +364,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.conflictManagement = cmIds.map((id) => {
|
out.conflictManagement = cmIds.map((id) => {
|
||||||
const preset = conflictManagementPresetFor(id);
|
const preset = conflictManagementPresetFor(id);
|
||||||
const override = state.conflictManagementDetailsById?.[id];
|
const override = state.conflictManagementDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */
|
/** Default consensus level used when a **catalog** preset omits a value. */
|
||||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||||
|
|
||||||
export function decisionApproachPresetFor(
|
export function decisionApproachPresetFor(
|
||||||
@@ -114,19 +114,22 @@ export function decisionApproachPresetFor(
|
|||||||
): DecisionApproachDetailEntry {
|
): DecisionApproachDetailEntry {
|
||||||
const method = findMethod(decisionApproachesMessages, id);
|
const method = findMethod(decisionApproachesMessages, id);
|
||||||
const s = method?.sections ?? {};
|
const s = method?.sections ?? {};
|
||||||
return {
|
const entry: DecisionApproachDetailEntry = {
|
||||||
corePrinciple: asString(s.corePrinciple),
|
corePrinciple: asString(s.corePrinciple),
|
||||||
applicableScope: asStringArray(s.applicableScope),
|
applicableScope: asStringArray(s.applicableScope),
|
||||||
selectedApplicableScope: [],
|
selectedApplicableScope: [],
|
||||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||||
consensusLevel: asNumberClamped(
|
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||||
|
};
|
||||||
|
if (method) {
|
||||||
|
entry.consensusLevel = asNumberClamped(
|
||||||
s.consensusLevel,
|
s.consensusLevel,
|
||||||
0,
|
0,
|
||||||
100,
|
100,
|
||||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||||
),
|
);
|
||||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
}
|
||||||
};
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conflictManagementPresetFor(
|
export function conflictManagementPresetFor(
|
||||||
|
|||||||
@@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks(
|
|||||||
prefill.headings.stepByStepInstructions,
|
prefill.headings.stepByStepInstructions,
|
||||||
prefill.draft.stepByStepInstructions,
|
prefill.draft.stepByStepInstructions,
|
||||||
),
|
),
|
||||||
{
|
);
|
||||||
|
if (
|
||||||
|
typeof prefill.draft.consensusLevel === "number" &&
|
||||||
|
facetPrefillHasContent(prefill)
|
||||||
|
) {
|
||||||
|
blocks.push({
|
||||||
kind: "proportion",
|
kind: "proportion",
|
||||||
id: "facet-consensusLevel",
|
id: "facet-consensusLevel",
|
||||||
blockTitle: prefill.headings.consensusLevel,
|
blockTitle: prefill.headings.consensusLevel,
|
||||||
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
blocks.push(
|
||||||
textBlock(
|
textBlock(
|
||||||
"facet-objectionsDeadlocks",
|
"facet-objectionsDeadlocks",
|
||||||
prefill.headings.objectionsDeadlocks,
|
prefill.headings.objectionsDeadlocks,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections(
|
|||||||
| Array<{
|
| Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
supportText?: string;
|
||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
) => {
|
) => {
|
||||||
@@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections(
|
|||||||
if (methodLabelFor(groupKey, id).length > 0) continue;
|
if (methodLabelFor(groupKey, id).length > 0) continue;
|
||||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
meta[id] = { label, supportText: "" };
|
const supportText =
|
||||||
|
typeof row.supportText === "string" ? row.supportText : "";
|
||||||
|
meta[id] = { label, supportText };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
absorb("communication", ms.communication);
|
absorb("communication", ms.communication);
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
|||||||
export type CommunityRuleEntryFromChipOptions = {
|
export type CommunityRuleEntryFromChipOptions = {
|
||||||
consensusLevelKey?: string;
|
consensusLevelKey?: string;
|
||||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||||
|
/** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */
|
||||||
|
supportText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Canonical `categoryName` strings for method groups in published documents. */
|
/** Canonical `categoryName` strings for method groups in published documents. */
|
||||||
@@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip(
|
|||||||
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
||||||
: [];
|
: [];
|
||||||
const blocks = [...presetBlocks, ...wizardBlocks];
|
const blocks = [...presetBlocks, ...wizardBlocks];
|
||||||
if (blocks.length === 0) return null;
|
const description = nonEmptyTrimmed(options?.supportText);
|
||||||
return { title, body: "", blocks };
|
if (blocks.length === 0) {
|
||||||
|
if (!description) return null;
|
||||||
|
return { title, body: description };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
body: description ?? "",
|
||||||
|
blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionApproachHasPublishableFacetCopy(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
nonEmptyTrimmed(sections.corePrinciple) ||
|
||||||
|
nonEmptyTrimmed(sections.stepByStepInstructions) ||
|
||||||
|
nonEmptyTrimmed(sections.objectionsDeadlocks) ||
|
||||||
|
formatScopePayload(sections.applicableScope) ||
|
||||||
|
formatScopePayload(sections.selectedApplicableScope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog methods publish their consensus figure. User-authored custom cards
|
||||||
|
* often seed `75` with empty facet copy — skip that unless the author actually
|
||||||
|
* filled decision sections. Wizard field blocks (including proportion) are the
|
||||||
|
* source of truth when present.
|
||||||
|
*/
|
||||||
|
function shouldPublishDecisionConsensusLevel(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
typeof sections.consensusLevel !== "number" ||
|
||||||
|
Number.isNaN(sections.consensusLevel)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (customFieldBlocks && customFieldBlocks.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return decisionApproachHasPublishableFacetCopy(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop seeded / wizard-superseded `consensusLevel` before publish or hydrate. */
|
||||||
|
export function withoutUnpublishedDecisionConsensus(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (shouldPublishDecisionConsensusLevel(sections, customFieldBlocks)) {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
if (!("consensusLevel" in sections)) return sections;
|
||||||
|
const next = { ...sections };
|
||||||
|
delete next.consensusLevel;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sectionFromCommunication(
|
export function sectionFromCommunication(
|
||||||
@@ -209,6 +267,7 @@ export function sectionFromCommunication(
|
|||||||
const sec = m.sections as unknown as Record<string, unknown>;
|
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],
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
});
|
});
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
@@ -227,6 +286,7 @@ export function sectionFromMembership(
|
|||||||
const sec = m.sections as unknown as Record<string, unknown>;
|
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],
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
});
|
});
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
@@ -254,10 +314,19 @@ export function sectionFromDecision(
|
|||||||
);
|
);
|
||||||
if (scope) merged.applicableScope = scope;
|
if (scope) merged.applicableScope = scope;
|
||||||
delete merged.selectedApplicableScope;
|
delete merged.selectedApplicableScope;
|
||||||
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
|
const e = communityRuleEntryFromMethodChip(
|
||||||
consensusLevelKey: "consensusLevel",
|
m.label,
|
||||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
withoutUnpublishedDecisionConsensus(
|
||||||
});
|
merged,
|
||||||
|
customFieldBlocksById?.[m.id],
|
||||||
|
),
|
||||||
|
DEC_LABELS,
|
||||||
|
{
|
||||||
|
consensusLevelKey: "consensusLevel",
|
||||||
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
return entries.length > 0
|
return entries.length > 0
|
||||||
@@ -281,6 +350,7 @@ export function sectionFromConflict(
|
|||||||
delete merged.selectedApplicableScope;
|
delete merged.selectedApplicableScope;
|
||||||
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
|
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
|
||||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
});
|
});
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const decisionApproachDetailEntrySchema = z.object({
|
|||||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||||
stepByStepInstructions: z.string().max(8000),
|
stepByStepInstructions: z.string().max(8000),
|
||||||
consensusLevel: z.number().int().min(0).max(100),
|
consensusLevel: z.number().int().min(0).max(100).optional(),
|
||||||
objectionsDeadlocks: z.string().max(8000),
|
objectionsDeadlocks: z.string().max(8000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,4 +50,26 @@ describe("CommunityRule", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("How proposals pass")).toBeInTheDocument();
|
expect(screen.getByText("How proposals pass")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders entry body together with labeled blocks", () => {
|
||||||
|
render(
|
||||||
|
<CommunityRule
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
categoryName: "Decision-making",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
title: "Forum proposals",
|
||||||
|
body: "Anyone can start a thread.",
|
||||||
|
blocks: [{ label: "Quorum", body: "60%" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("60%")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -519,6 +519,25 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes the chip edit modal when Back is pressed", async () => {
|
||||||
|
render(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
onState={() => {}}
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
selectedCommunicationMethodIds: ["signal"],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Back" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,4 +45,17 @@ describe("TextBlock", () => {
|
|||||||
"/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
"/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders body paragraphs above labeled rows when both are set", () => {
|
||||||
|
render(
|
||||||
|
<TextBlock
|
||||||
|
title="Forum proposals"
|
||||||
|
body="Anyone can start a thread."
|
||||||
|
rows={[{ label: "Quorum", body: "60%" }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("60%")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -249,6 +249,55 @@ describe("buildPublishPayload — methodSelections", () => {
|
|||||||
expect(entry.sections.corePrinciple.length).toBeGreaterThan(0);
|
expect(entry.sections.corePrinciple.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not seed 75% consensus on a custom decision-making card", () => {
|
||||||
|
const customId = "00000000-0000-4000-8000-000000000170";
|
||||||
|
const r = buildPublishPayload({
|
||||||
|
title: "T",
|
||||||
|
selectedDecisionApproachIds: [customId],
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
[customId]: {
|
||||||
|
label: "Forum proposals",
|
||||||
|
supportText: "Anyone can start a thread.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decisionApproachDetailsById: {
|
||||||
|
[customId]: {
|
||||||
|
corePrinciple: "",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "",
|
||||||
|
consensusLevel: 75,
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const ms = r.document.methodSelections as {
|
||||||
|
decisionApproaches?: Array<{
|
||||||
|
supportText?: string;
|
||||||
|
sections: { consensusLevel?: number };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
expect(ms.decisionApproaches?.[0]?.supportText).toBe(
|
||||||
|
"Anyone can start a thread.",
|
||||||
|
);
|
||||||
|
expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps catalog decision consensus in methodSelections", () => {
|
||||||
|
const r = buildPublishPayload({
|
||||||
|
title: "T",
|
||||||
|
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const ms = r.document.methodSelections as {
|
||||||
|
decisionApproaches?: Array<{ sections: { consensusLevel?: number } }>;
|
||||||
|
};
|
||||||
|
expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it("merges override on top of preset for the selected method", () => {
|
it("merges override on top of preset for the selected method", () => {
|
||||||
const r = buildPublishPayload({
|
const r = buildPublishPayload({
|
||||||
title: "T",
|
title: "T",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildMethodCardWizardInitialValues,
|
buildMethodCardWizardInitialValues,
|
||||||
coreValueDetailsFromWizardFieldBlocks,
|
coreValueDetailsFromWizardFieldBlocks,
|
||||||
facetDetailsToWizardFieldBlocks,
|
facetDetailsToWizardFieldBlocks,
|
||||||
|
overlayFacetPrefillValues,
|
||||||
} from "../../lib/create/methodCardWizardPrefill";
|
} from "../../lib/create/methodCardWizardPrefill";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
|
||||||
@@ -219,6 +220,49 @@ describe("facetDetailsToWizardFieldBlocks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits a consensus proportion when the draft has no consensus level", () => {
|
||||||
|
const blocksOut = facetDetailsToWizardFieldBlocks({
|
||||||
|
group: "decisionApproaches",
|
||||||
|
draft: {
|
||||||
|
corePrinciple: "Momentum.",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "Post a deadline.",
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
corePrinciple: "Core Principle",
|
||||||
|
applicableScope: "Applicable Scope",
|
||||||
|
stepByStepInstructions: "Step-by-Step Instructions",
|
||||||
|
consensusLevel: "Consensus Level",
|
||||||
|
objectionsDeadlocks: "Objections & Deadlocks",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(blocksOut.find((b) => b.kind === "proportion")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject a seeded 75% proportion onto empty custom drafts", () => {
|
||||||
|
const next = overlayFacetPrefillValues([], {
|
||||||
|
group: "decisionApproaches",
|
||||||
|
draft: {
|
||||||
|
corePrinciple: "",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "",
|
||||||
|
consensusLevel: 75,
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
corePrinciple: "Core Principle",
|
||||||
|
applicableScope: "Applicable Scope",
|
||||||
|
stepByStepInstructions: "Step-by-Step Instructions",
|
||||||
|
consensusLevel: "Consensus Level",
|
||||||
|
objectionsDeadlocks: "Objections & Deadlocks",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps core value meaning and signals onto text blocks", () => {
|
it("maps core value meaning and signals onto text blocks", () => {
|
||||||
expect(
|
expect(
|
||||||
facetDetailsToWizardFieldBlocks({
|
facetDetailsToWizardFieldBlocks({
|
||||||
|
|||||||
@@ -314,6 +314,37 @@ describe("createFlowStateFromPublishedRule", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hydrates wizard supportText from published methodSelections", () => {
|
||||||
|
const customId = "b7c0a9f3-0000-4000-8000-000000000002";
|
||||||
|
const partial = createFlowStateFromPublishedRule({
|
||||||
|
id: "rule-custom-desc",
|
||||||
|
title: "C",
|
||||||
|
summary: "",
|
||||||
|
document: {
|
||||||
|
methodSelections: {
|
||||||
|
decisionApproaches: [
|
||||||
|
{
|
||||||
|
id: customId,
|
||||||
|
label: "Forum proposals",
|
||||||
|
supportText: "Anyone can start a thread.",
|
||||||
|
sections: {
|
||||||
|
corePrinciple: "",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "",
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(partial.customMethodCardMetaById?.[customId]).toEqual({
|
||||||
|
label: "Forum proposals",
|
||||||
|
supportText: "Anyone can start a thread.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
||||||
const partial = createFlowStateFromPublishedRule({
|
const partial = createFlowStateFromPublishedRule({
|
||||||
id: "rule-2",
|
id: "rule-2",
|
||||||
|
|||||||
@@ -248,4 +248,35 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows custom decision description without a seeded 75% consensus row", () => {
|
||||||
|
const customId = "00000000-0000-4000-8000-000000000170";
|
||||||
|
const out = parsePublishedDocumentForCommunityRuleDisplay({
|
||||||
|
sections: [],
|
||||||
|
methodSelections: {
|
||||||
|
decisionApproaches: [
|
||||||
|
{
|
||||||
|
id: customId,
|
||||||
|
label: "Forum proposals",
|
||||||
|
supportText: "Anyone can start a thread.",
|
||||||
|
sections: {
|
||||||
|
corePrinciple: "",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "",
|
||||||
|
consensusLevel: 75,
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const decision = out.find((s) => s.categoryName === "Decision-making");
|
||||||
|
expect(decision?.entries).toEqual([
|
||||||
|
{
|
||||||
|
title: "Forum proposals",
|
||||||
|
body: "Anyone can start a thread.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
communityRuleEntryFromMethodChip,
|
||||||
|
sectionFromDecision,
|
||||||
|
withoutUnpublishedDecisionConsensus,
|
||||||
|
} from "../../lib/create/ruleSectionsFromMethodSelections";
|
||||||
|
|
||||||
|
const emptyDecisionSections = {
|
||||||
|
corePrinciple: "",
|
||||||
|
applicableScope: [] as string[],
|
||||||
|
selectedApplicableScope: [] as string[],
|
||||||
|
stepByStepInstructions: "",
|
||||||
|
consensusLevel: 75,
|
||||||
|
objectionsDeadlocks: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("withoutUnpublishedDecisionConsensus", () => {
|
||||||
|
it("keeps catalog consensus when facet copy is present", () => {
|
||||||
|
const next = withoutUnpublishedDecisionConsensus({
|
||||||
|
corePrinciple: "Momentum.",
|
||||||
|
consensusLevel: 100,
|
||||||
|
});
|
||||||
|
expect(next.consensusLevel).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops seeded consensus on empty custom facet copy", () => {
|
||||||
|
const next = withoutUnpublishedDecisionConsensus({ ...emptyDecisionSections });
|
||||||
|
expect(next.consensusLevel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops keyed consensus when wizard field blocks exist", () => {
|
||||||
|
const next = withoutUnpublishedDecisionConsensus(
|
||||||
|
{ corePrinciple: "Momentum.", consensusLevel: 75 },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "proportion",
|
||||||
|
id: "p1",
|
||||||
|
blockTitle: "Quorum",
|
||||||
|
defaultPercent: 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(next.consensusLevel).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("communityRuleEntryFromMethodChip", () => {
|
||||||
|
it("publishes supportText as body when there are no labeled blocks", () => {
|
||||||
|
expect(
|
||||||
|
communityRuleEntryFromMethodChip(
|
||||||
|
"Our process",
|
||||||
|
{ corePrinciple: "" },
|
||||||
|
{ corePrinciple: "Core Principle" },
|
||||||
|
{ supportText: " Members propose in the forum. " },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
title: "Our process",
|
||||||
|
body: "Members propose in the forum.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps supportText alongside wizard blocks", () => {
|
||||||
|
const entry = communityRuleEntryFromMethodChip(
|
||||||
|
"Our process",
|
||||||
|
{ corePrinciple: "" },
|
||||||
|
{ corePrinciple: "Core Principle" },
|
||||||
|
{
|
||||||
|
supportText: "How we decide.",
|
||||||
|
customFieldBlocks: [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
id: "b1",
|
||||||
|
blockTitle: "Notes",
|
||||||
|
placeholderText: "Post in #governance.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(entry).toEqual({
|
||||||
|
title: "Our process",
|
||||||
|
body: "How we decide.",
|
||||||
|
blocks: [{ label: "Notes", body: "Post in #governance." }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sectionFromDecision", () => {
|
||||||
|
it("omits seeded 75% and shows the policy description on a custom card", () => {
|
||||||
|
const section = sectionFromDecision([
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
label: "Forum proposals",
|
||||||
|
supportText: "Anyone can start a thread; silence after a week is consent.",
|
||||||
|
sections: { ...emptyDecisionSections },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(section?.categoryName).toBe("Decision-making");
|
||||||
|
expect(section?.entries).toEqual([
|
||||||
|
{
|
||||||
|
title: "Forum proposals",
|
||||||
|
body: "Anyone can start a thread; silence after a week is consent.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits catalog consensus when facet copy is present", () => {
|
||||||
|
const section = sectionFromDecision([
|
||||||
|
{
|
||||||
|
id: "lazy-consensus",
|
||||||
|
label: "Lazy Consensus",
|
||||||
|
sections: {
|
||||||
|
corePrinciple: "Silence is consent.",
|
||||||
|
applicableScope: [],
|
||||||
|
selectedApplicableScope: [],
|
||||||
|
stepByStepInstructions: "Post a deadline.",
|
||||||
|
consensusLevel: 100,
|
||||||
|
objectionsDeadlocks: "Any member can block.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const blocks = section?.entries[0]?.blocks ?? [];
|
||||||
|
expect(blocks).toContainEqual({
|
||||||
|
label: "Consensus Level",
|
||||||
|
body: "100%",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate consensus when a wizard proportion block exists", () => {
|
||||||
|
const section = sectionFromDecision(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000002",
|
||||||
|
label: "Custom vote",
|
||||||
|
supportText: "We vote in person.",
|
||||||
|
sections: {
|
||||||
|
...emptyDecisionSections,
|
||||||
|
consensusLevel: 75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"00000000-0000-4000-8000-000000000002": [
|
||||||
|
{
|
||||||
|
kind: "proportion",
|
||||||
|
id: "q",
|
||||||
|
blockTitle: "Quorum",
|
||||||
|
defaultPercent: 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const blocks = section?.entries[0]?.blocks ?? [];
|
||||||
|
expect(blocks).toEqual([{ label: "Quorum", body: "60%" }]);
|
||||||
|
expect(section?.entries[0]?.body).toBe("We vote in person.");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user