Fix magic-link verify URLs in email and related create-flow QA #70
@@ -1078,6 +1078,7 @@ export function FinalReviewChipEditModal({
|
||||
<Create
|
||||
isOpen={isOpen && !addCustomWizardOpen}
|
||||
onClose={handleModalClose}
|
||||
onBack={handleModalClose}
|
||||
backdropVariant="blurredYellow"
|
||||
headerContent={headerContent}
|
||||
showNextButton={true}
|
||||
|
||||
@@ -80,19 +80,21 @@ function DecisionApproachEditFieldsComponent({
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
helpIcon={false}
|
||||
value={value.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => patch("consensusLevel", next)}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{value.consensusLevel !== undefined ? (
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
helpIcon={false}
|
||||
value={value.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => patch("consensusLevel", next)}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : null}
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.objectionsDeadlocks}
|
||||
value={value.objectionsDeadlocks}
|
||||
|
||||
@@ -85,7 +85,11 @@ export type DecisionApproachDetailEntry = {
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ export interface CommunityRuleEntry {
|
||||
/** Plain text; split on blank lines into paragraphs when rendering. */
|
||||
body: string;
|
||||
/**
|
||||
* When set, rendered as Figma-style label + body stacks. If non-empty, takes
|
||||
* precedence over {@link body} for main content (body may be empty).
|
||||
* Figma-style label + body stacks (facet sections, wizard fields). Shown
|
||||
* after {@link body} when both are present.
|
||||
*/
|
||||
blocks?: CommunityRuleLabeledBlock[];
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ function CommunityRuleView({
|
||||
<TextBlock
|
||||
key={entryIndex}
|
||||
title={entry.title}
|
||||
body={hasBlocks ? undefined : entry.body}
|
||||
body={entry.body}
|
||||
rows={hasBlocks ? entry.blocks : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -94,9 +94,10 @@ function TextBlockView({
|
||||
>
|
||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
|
||||
{hasRows
|
||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
publishedMethodDisplayLabel,
|
||||
} from "./finalReviewChipPresets";
|
||||
import { isDocumentEntry } from "./documentEntryGuards";
|
||||
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
|
||||
import {
|
||||
replaceMethodSectionsWithMethodSelections,
|
||||
withoutUnpublishedDecisionConsensus,
|
||||
} from "./ruleSectionsFromMethodSelections";
|
||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||
|
||||
export { isDocumentEntry } from "./documentEntryGuards";
|
||||
@@ -78,21 +81,25 @@ export type PublishedMethodSelections = {
|
||||
id: string;
|
||||
label: string;
|
||||
sections: CommunicationMethodDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
membership?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: MembershipMethodDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
decisionApproaches?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: DecisionApproachDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
conflictManagement?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: ConflictManagementDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -247,6 +254,14 @@ function pickMethodIds(
|
||||
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`
|
||||
* overrides authored on the final-review screen. Preset defaults from the
|
||||
@@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument(
|
||||
out.communication = commIds.map((id) => {
|
||||
const preset = communicationPresetFor(id);
|
||||
const override = state.communicationMethodDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument(
|
||||
out.membership = memIds.map((id) => {
|
||||
const preset = membershipPresetFor(id);
|
||||
const override = state.membershipMethodDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument(
|
||||
out.decisionApproaches = daIds.map((id) => {
|
||||
const preset = decisionApproachPresetFor(id);
|
||||
const override = state.decisionApproachDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
const merged = override ? { ...preset, ...override } : preset;
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument(
|
||||
id,
|
||||
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) => {
|
||||
const preset = conflictManagementPresetFor(id);
|
||||
const override = state.conflictManagementDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
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 function decisionApproachPresetFor(
|
||||
@@ -114,19 +114,22 @@ export function decisionApproachPresetFor(
|
||||
): DecisionApproachDetailEntry {
|
||||
const method = findMethod(decisionApproachesMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
const entry: DecisionApproachDetailEntry = {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
applicableScope: asStringArray(s.applicableScope),
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||
consensusLevel: asNumberClamped(
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
if (method) {
|
||||
entry.consensusLevel = asNumberClamped(
|
||||
s.consensusLevel,
|
||||
0,
|
||||
100,
|
||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||
),
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function conflictManagementPresetFor(
|
||||
|
||||
@@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks(
|
||||
prefill.headings.stepByStepInstructions,
|
||||
prefill.draft.stepByStepInstructions,
|
||||
),
|
||||
{
|
||||
);
|
||||
if (
|
||||
typeof prefill.draft.consensusLevel === "number" &&
|
||||
facetPrefillHasContent(prefill)
|
||||
) {
|
||||
blocks.push({
|
||||
kind: "proportion",
|
||||
id: "facet-consensusLevel",
|
||||
blockTitle: prefill.headings.consensusLevel,
|
||||
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
||||
},
|
||||
});
|
||||
}
|
||||
blocks.push(
|
||||
textBlock(
|
||||
"facet-objectionsDeadlocks",
|
||||
prefill.headings.objectionsDeadlocks,
|
||||
|
||||
@@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections(
|
||||
| Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
supportText?: string;
|
||||
}>
|
||||
| undefined,
|
||||
) => {
|
||||
@@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections(
|
||||
if (methodLabelFor(groupKey, id).length > 0) continue;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
if (!label) continue;
|
||||
meta[id] = { label, supportText: "" };
|
||||
const supportText =
|
||||
typeof row.supportText === "string" ? row.supportText : "";
|
||||
meta[id] = { label, supportText };
|
||||
}
|
||||
};
|
||||
absorb("communication", ms.communication);
|
||||
|
||||
@@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
||||
export type CommunityRuleEntryFromChipOptions = {
|
||||
consensusLevelKey?: string;
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||
/** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */
|
||||
supportText?: string;
|
||||
};
|
||||
|
||||
/** Canonical `categoryName` strings for method groups in published documents. */
|
||||
@@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip(
|
||||
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
||||
: [];
|
||||
const blocks = [...presetBlocks, ...wizardBlocks];
|
||||
if (blocks.length === 0) return null;
|
||||
return { title, body: "", blocks };
|
||||
const description = nonEmptyTrimmed(options?.supportText);
|
||||
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(
|
||||
@@ -209,6 +267,7 @@ export function sectionFromCommunication(
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
@@ -227,6 +286,7 @@ export function sectionFromMembership(
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
@@ -254,10 +314,19 @@ export function sectionFromDecision(
|
||||
);
|
||||
if (scope) merged.applicableScope = scope;
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
|
||||
consensusLevelKey: "consensusLevel",
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
const e = communityRuleEntryFromMethodChip(
|
||||
m.label,
|
||||
withoutUnpublishedDecisionConsensus(
|
||||
merged,
|
||||
customFieldBlocksById?.[m.id],
|
||||
),
|
||||
DEC_LABELS,
|
||||
{
|
||||
consensusLevelKey: "consensusLevel",
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
},
|
||||
);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
return entries.length > 0
|
||||
@@ -281,6 +350,7 @@ export function sectionFromConflict(
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const decisionApproachDetailEntrySchema = z.object({
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
stepByStepInstructions: z.string().max(8000),
|
||||
consensusLevel: z.number().int().min(0).max(100),
|
||||
consensusLevel: z.number().int().min(0).max(100).optional(),
|
||||
objectionsDeadlocks: z.string().max(8000),
|
||||
});
|
||||
|
||||
|
||||
@@ -50,4 +50,26 @@ describe("CommunityRule", () => {
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildMethodCardWizardInitialValues,
|
||||
coreValueDetailsFromWizardFieldBlocks,
|
||||
facetDetailsToWizardFieldBlocks,
|
||||
overlayFacetPrefillValues,
|
||||
} from "../../lib/create/methodCardWizardPrefill";
|
||||
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", () => {
|
||||
expect(
|
||||
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)", () => {
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
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