Merge pull request 'Fix magic-link verify URLs in email and related create-flow QA' (#70) from adilallo/fix/QaFeedback into main

Reviewed-on: #70
This commit was merged in pull request #70.
This commit is contained in:
2026-09-01 16:09:31 +00:00
56 changed files with 1292 additions and 160 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"title": "Community Rule", "title": "Community Rule",
"author": "MEDLab", "author": "MEDLab",
"description": "Community governance and rule-building app", "description": "Community governance and rule-building app",
"version": "0.1.11", "version": "0.1.12",
"httpPort": 3000, "httpPort": 3000,
"healthCheckPath": "/api/health", "healthCheckPath": "/api/health",
"memoryLimit": 805306368, "memoryLimit": 805306368,
@@ -6,6 +6,7 @@ import {
useTranslation, useTranslation,
} from "../../../../contexts/MessagesContext"; } from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
@@ -213,6 +214,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyTitle, policyTitle,
]); ]);
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
const confirmAbandonWizardEdits = useCallback(async () => { const confirmAbandonWizardEdits = useCallback(async () => {
if (!isWizardSessionDirty()) { if (!isWizardSessionDirty()) {
return true; return true;
@@ -28,6 +28,7 @@ import {
import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import { import {
communicationPresetFor, communicationPresetFor,
conflictManagementPresetFor, conflictManagementPresetFor,
@@ -370,6 +371,12 @@ export function FinalReviewChipEditModal({
draftFieldBlocks, draftFieldBlocks,
]); ]);
useBeforeUnloadGuard(
isOpen &&
!addCustomWizardOpen &&
(!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled),
);
const modalUsesWizardFieldBlocksBody = Boolean( const modalUsesWizardFieldBlocksBody = Boolean(
target && target &&
(usesWizardFieldBlocksModalBody({ (usesWizardFieldBlocksModalBody({
@@ -1071,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}
@@ -6,6 +6,7 @@
*/ */
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput"; import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup"; import ContentLockup from "../../../components/type/ContentLockup";
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
[draft], [draft],
); );
useBeforeUnloadGuard(isOpen && isDirty);
const characterHint = tField("characterCountTemplate") const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length)) .replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH)); .replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
@@ -15,7 +15,7 @@ import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps { export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry; value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void; onChange: (_next: CoreValueDetailEntry) => void;
/** Disable meaning/signals. Create-flow core-values omits this; final-review locks until Customize. */ /** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */
readOnly?: boolean; readOnly?: boolean;
} }
@@ -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}
@@ -10,8 +10,8 @@
* *
* Card click opens the Figma create modal (node `20246-15829`) with three * Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary * editable sections rendered by {@link CommunicationMethodEditFields}. The primary
* action is **Add Platform** for an unselected card; a selected card in view mode has * action is **Add Platform** for an unselected card and **Save** for a selected
* no footer primary — **Remove** is available from the kebab (same behavior as legacy * card. **Remove** is available from the kebab (same behavior as legacy
* footer remove via {@link removeMethodCardFromFacetSelection}). * footer remove via {@link removeMethodCardFromFacetSelection}).
*/ */
@@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -57,6 +58,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -88,6 +90,16 @@ export function CommunicationMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedCommunicationMethodIds ?? []; const selectedIds = state.selectedCommunicationMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -216,7 +228,7 @@ export function CommunicationMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -54,6 +55,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -85,6 +87,16 @@ export function ConflictManagementScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedConflictManagementIds ?? []; const selectedIds = state.selectedConflictManagementIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -217,7 +229,7 @@ export function ConflictManagementScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -55,6 +56,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -86,6 +88,16 @@ export function MembershipMethodsScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedMembershipMethodIds ?? []; const selectedIds = state.selectedMembershipMethodIds ?? [];
const mergedMethods = useMemo( const mergedMethods = useMemo(
@@ -214,7 +226,7 @@ export function MembershipMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -91,6 +93,16 @@ export function DecisionApproachesScreen() {
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({ const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById, detailsById: state.decisionApproachDetailsById,
@@ -250,7 +262,7 @@ export function DecisionApproachesScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: false, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { import type {
CommunityStructureChipSnapshotRow, CommunityStructureChipSnapshotRow,
CoreValueDetailEntry, CoreValueDetailEntry,
@@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() {
}); });
}, [cv.detailModal, draft, modalSession, requestConfirm]); }, [cv.detailModal, draft, modalSession, requestConfirm]);
useBeforeUnloadGuard(
activeModalChipId != null &&
!addCustomWizardOpen &&
initialDraftRef.current != null &&
(draft.meaning !== initialDraftRef.current.meaning ||
draft.signals !== initialDraftRef.current.signals),
);
const handleDuplicateCoreChip = useCallback(() => { const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
+5 -1
View File
@@ -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;
}; };
@@ -80,7 +80,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
}, },
}; };
// State styles (embedded: Figma 20736-12668 borderless, darker grey block, white text) // Embedded (Figma 20736-12668): borderless grey block; default copy is
// tertiary, primary on focus so seeded modal fields stay muted until edit.
const getStateStyles = (): { const getStateStyles = (): {
textarea: string; textarea: string;
label: string; label: string;
@@ -89,13 +90,13 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
if (disabled) { if (disabled) {
return { return {
textarea: textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60", "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] cursor-not-allowed opacity-60",
label: "text-[var(--color-content-default-secondary)]", label: "text-[var(--color-content-default-secondary)]",
}; };
} }
return { return {
textarea: textarea:
"border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset", "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)] focus:text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset",
label: "text-[var(--color-content-default-secondary)]", label: "text-[var(--color-content-default-secondary)]",
}; };
} }
@@ -48,7 +48,8 @@ export interface TextAreaProps extends Omit<
showHelpIcon?: boolean; showHelpIcon?: boolean;
/** /**
* Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668): * Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668):
* borderless, darker grey background, white text. "default" is standard bordered input. * borderless, darker grey background, tertiary text in default (primary on
* focus). "default" is the standard bordered input.
* @default "default" * @default "default"
*/ */
appearance?: TextAreaAppearanceValue; appearance?: TextAreaAppearanceValue;
@@ -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>
); );
+1
View File
@@ -18,6 +18,7 @@ export { useMediaQuery,
} from "./useMediaQuery"; } from "./useMediaQuery";
export { useAsyncConfirm } from "./useAsyncConfirm"; export { useAsyncConfirm } from "./useAsyncConfirm";
export type { AsyncConfirmOptions } from "./useAsyncConfirm"; export type { AsyncConfirmOptions } from "./useAsyncConfirm";
export { useBeforeUnloadGuard } from "./useBeforeUnloadGuard";
export type { export type {
SchemaOrganization, SchemaOrganization,
SchemaWebSite, SchemaWebSite,
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
/**
* Attach the browsers native leave-site prompt while `enabled` is true.
*
* Modern browsers ignore custom copy; this only blocks accidental tab or
* window close. No-ops during SSR and when `enabled` is false so clean
* sessions never register a listener.
*
* @param enabled Whether unsaved work would be lost on unload.
*
* @example
* useBeforeUnloadGuard(isWizardSessionDirty());
*/
export function useBeforeUnloadGuard(enabled: boolean): void {
useEffect(() => {
if (!enabled || typeof window === "undefined") {
return;
}
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [enabled]);
}
+1 -1
View File
@@ -227,7 +227,7 @@ npm run dev
1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._ 1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._
2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._ 2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._
3. SMTP DNS (SPF, DKIM). _On Cloudron: handled for the platform-managed domain._ 3. SMTP DNS (SPF, DKIM). _TLS for the app hostname is Cloudron/Let's Encrypt. Mail is SES-relayed: publish SES DKIM (and SPF `include:amazonses.com`) via Cloudron Domains → Namecheap. Cloudron skips SPF/DKIM checks when a relay is configured. See [`ops-runbook.md`](ops-runbook.md) §8.1._
4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._ 4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._
5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._ 5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._
+11 -3
View File
@@ -101,8 +101,16 @@ per-app in the manifest and provisioned at install time.
- Backups: Cloudron's automatic backups are already on for the host - Backups: Cloudron's automatic backups are already on for the host
(legacy app shows weekly snapshots ~451 MB each). Same default (legacy app shows weekly snapshots ~451 MB each). Same default
applies to new apps. applies to new apps.
- TLS / DNS / SPF / DKIM: handled by Cloudron for any subdomain of - TLS for Cloudron app hostnames: handled by Cloudron (Let's Encrypt).
`communityrule.info`. - **Mail DNS (SPF/DKIM):** *not* automatic for this domain. Cloudron's
DNS provider for `communityrule.info` is Namecheap, but outbound mail
is **Amazon SES relay**. Cloudron's own mail-status check **skips**
SPF and DKIM and says to configure them on the relay. Add the SES
identity's **DKIM CNAME** records (and optionally
`include:amazonses.com` on SPF) in Cloudron → *Domains*
`communityrule.info` → DNS so they publish to Namecheap. See
[`ops-runbook.md`](ops-runbook.md) §8.1. DMARC on the domain is
currently `p=reject`.
## 5. Cutover plan (side-by-side, never in-place) ## 5. Cutover plan (side-by-side, never in-place)
@@ -476,7 +484,7 @@ steps below are still required.
| ------- | ------------ | ----- | | ------- | ------------ | ----- |
| Image pull error on install | Repo still private, or wrong tag in manifest | §6.3; `docker pull --platform linux/amd64 …` from laptop | | Image pull error on install | Repo still private, or wrong tag in manifest | §6.3; `docker pull --platform linux/amd64 …` from laptop |
| Health `503` / `database: disconnected` | Postgres addon not provisioned or URL missing | Cloudron app → Environment; expect `CLOUDRON_POSTGRESQL_URL` | | Health `503` / `database: disconnected` | Postgres addon not provisioned or URL missing | Cloudron app → Environment; expect `CLOUDRON_POSTGRESQL_URL` |
| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars | | Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*`; [ops-runbook §8.1](ops-runbook.md#81-mail-dns-when-ses-is-the-relay) |
| Upload `server_misconfigured` | `UPLOAD_ROOT` unset | Set to `/app/data/uploads` (§3) | | Upload `server_misconfigured` | `UPLOAD_ROOT` unset | Set to `/app/data/uploads` (§3) |
| Container crash on start | Migration failure | App logs around `prisma migrate deploy` | | Container crash on start | Migration failure | App logs around `prisma migrate deploy` |
| No "Recommended" on method cards | `MethodFacet` not seeded | §10 step 6; API should return `matches.score > 0` for some methods when `facet.*` set | | No "Recommended" on method cards | `MethodFacet` not seeded | §10 step 6; API should return `matches.score > 0` for some methods when `facet.*` set |
+17 -1
View File
@@ -256,13 +256,29 @@ Full detail: [`ops-backend-deploy.md` §3](ops-backend-deploy.md#3-environment-v
| Image pull error on update | Private repo, wrong tag, or amd64 manifest missing | Confirm repo is public; verify pull with `--platform linux/amd64` (§3.1) | | Image pull error on update | Private repo, wrong tag, or amd64 manifest missing | Confirm repo is public; verify pull with `--platform linux/amd64` (§3.1) |
| Health `503` / `database: disconnected` | Postgres addon or `CLOUDRON_POSTGRESQL_URL` missing | Cloudron app → Environment | | Health `503` / `database: disconnected` | Postgres addon or `CLOUDRON_POSTGRESQL_URL` missing | Cloudron app → Environment |
| Container crash on start | Migration failure | App logs around `prisma migrate deploy`; fix forward with new migration | | Container crash on start | Migration failure | App logs around `prisma migrate deploy`; fix forward with new migration |
| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars | | Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs (`CLOUDRON_MAIL_SMTP_*`); inbox/spam; SPF/DKIM for SES (§8.1) |
| Upload `server_misconfigured` | `UPLOAD_ROOT` unset | `cloudron env set --app <app> UPLOAD_ROOT=/app/data/uploads` | | Upload `server_misconfigured` | `UPLOAD_ROOT` unset | `cloudron env set --app <app> UPLOAD_ROOT=/app/data/uploads` |
| No “Recommended” on method cards | Seed not run | §3.4 — `node prisma/seed.bundle.cjs` | | No “Recommended” on method cards | Seed not run | §3.4 — `node prisma/seed.bundle.cjs` |
| Rate limit too aggressive after deploy | Expected per §6.1 | Single instance only; limits reset on container restart | | Rate limit too aggressive after deploy | Expected per §6.1 | Single instance only; limits reset on container restart |
App logs: Cloudron dashboard → *Logs* tab, or `cloudron logs --app <app> -f`. App logs: Cloudron dashboard → *Logs* tab, or `cloudron logs --app <app> -f`.
### 8.1 Mail DNS when SES is the relay
`communityrule.info` outbound mail is **Amazon SES SMTP** (`email-smtp.us-east-2.amazonaws.com:587`), not Cloudron's own MTA. Cloudron Mail → domain status therefore **skips SPF and DKIM** ("configure the relay provider") and only checks MX, DMARC (`v=DMARC1; p=reject; pct=100`), and that the SES connection works.
That is expected. Recipients still authenticate the visible `From:` (`staging.app@communityrule.info` on staging) against **SES DKIM/SPF**, not `a:my.medlab.host`.
**Operator steps (AWS + Cloudron DNS, not app code):**
1. In **AWS SES** (us-east-2), open the verified identity for `communityrule.info` (create one if missing). Copy the **DKIM CNAME** records SES shows (three `*._domainkey.communityrule.info` names).
2. In **Cloudron** → *Domains* → `communityrule.info` → DNS, add those CNAMEs. Cloudron's Namecheap provider publishes them to the registrar. Confirm with `dig +short CNAME <selector>._domainkey.communityrule.info`.
3. Optional but recommended for SPF alignment: add `include:amazonses.com` to the existing TXT SPF, e.g. `v=spf1 include:amazonses.com a:my.medlab.host ~all`. Do not remove `a:my.medlab.host` until you know nothing still sends directly from the box.
4. Leave DMARC at `p=reject` once DKIM verifies in SES; if a provider still quarantines after DKIM is live, inspect that provider's headers before relaxing DMARC.
5. Retest: request a magic link to Gmail **and** a non-Gmail inbox (May First / university). Check spam. Staging From is `Community Rule <staging.app@communityrule.info>`.
`SMTP_FROM` should stay the Cloudron mailbox (`staging.app@communityrule.info` on staging, `hello@communityrule.info` on the apex app). The app falls back to `CLOUDRON_MAIL_FROM` if `SMTP_FROM` is unset.
## 9. Related docs ## 9. Related docs
- [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover - [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover
+23 -36
View File
@@ -27,6 +27,26 @@ function readApiErrorMessage(data: unknown): string {
return "Request failed"; return "Request failed";
} }
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function fetchAuthSession(): Promise<{ export async function fetchAuthSession(): Promise<{
user: { id: string; email: string } | null; user: { id: string; email: string } | null;
}> { }> {
@@ -54,13 +74,12 @@ export async function requestMagicLink(
...(draft && Object.keys(draft).length > 0 ? { draft } : {}), ...(draft && Object.keys(draft).length > 0 ? { draft } : {}),
}), }),
}); });
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res); const data: unknown = await parseJson(res);
if (!res.ok) { if (!res.ok) {
return { return {
ok: false, ok: false,
error: readApiErrorMessage(data), error: readApiErrorMessage(data),
retryAfterMs: retryAfterMs: retryAfterFromResponse(res, data),
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
}; };
} }
return { ok: true }; return { ok: true };
@@ -85,22 +104,10 @@ export async function requestEmailChange(
}); });
const data: unknown = await res.json().catch(() => ({})); const data: unknown = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
let retryAfterMs: number | undefined;
if (
res.status === 429 &&
data &&
typeof data === "object" &&
"details" in data
) {
const d = (data as { details?: { retryAfterMs?: unknown } }).details;
if (d && typeof d.retryAfterMs === "number") {
retryAfterMs = d.retryAfterMs;
}
}
return { return {
ok: false, ok: false,
error: readApiErrorMessage(data), error: readApiErrorMessage(data),
retryAfterMs, retryAfterMs: retryAfterFromResponse(res, data),
}; };
} }
return { ok: true }; return { ok: true };
@@ -438,26 +445,6 @@ export type RuleStakeholderMutationResult =
| { ok: true } | { ok: true }
| { ok: false; error: string; status: number; retryAfterMs?: number }; | { ok: false; error: string; status: number; retryAfterMs?: number };
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function addRuleStakeholder( export async function addRuleStakeholder(
ruleId: string, ruleId: string,
email: string, email: string,
+41 -2
View File
@@ -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 } : {}),
}; };
}); });
} }
+9 -6
View File
@@ -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(
+22
View File
@@ -25,6 +25,28 @@ export function captureMethodCardCustomizeSnapshot<TDraft>(
}; };
} }
/**
* True when a method-card create/customize modal is open with edits that
* are not yet persisted — the condition for a tab/window `beforeunload` guard.
*/
export function isMethodCardCustomizeUnloadBlocked<TDraft>(
modalOpen: boolean,
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
): boolean {
if (!modalOpen || snapshot === null) {
return false;
}
return isMethodCardCustomizeSessionDirty(
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
);
}
export function isMethodCardCustomizeSessionDirty<TDraft>( export function isMethodCardCustomizeSessionDirty<TDraft>(
snapshot: MethodCardCustomizeSnapshot<TDraft>, snapshot: MethodCardCustomizeSnapshot<TDraft>,
pendingDraft: TDraft | null, pendingDraft: TDraft | null,
+9 -2
View File
@@ -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);
+76 -6
View File
@@ -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);
} }
@@ -29,6 +29,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId";
* so meta-only wizard cards show policy copy instead of empty section editors. * so meta-only wizard cards show policy copy instead of empty section editors.
* Pass `customFacetDetailsMatchPreset: false` when the caller knows facet details * Pass `customFacetDetailsMatchPreset: false` when the caller knows facet details
* were edited or cloned from a filled preset. * were edited or cloned from a filled preset.
*
* Create-flow facet screens and final-review pass `modalEditUnlocked: true` so
* section fields stay editable on open (Customize is the kebab wizard).
*/ */
export function usesWizardFieldBlocksModalBody(args: { export function usesWizardFieldBlocksModalBody(args: {
methodId: string; methodId: string;
+99 -68
View File
@@ -2,62 +2,108 @@ import nodemailer from "nodemailer";
import { logger } from "../logger"; import { logger } from "../logger";
import { getSmtpUrl } from "./env"; import { getSmtpUrl } from "./env";
export async function sendMagicLinkEmail( function escapeHtml(value: string): string {
to: string, return value
verifyUrl: string, .replace(/&/g, "&amp;")
): Promise<void> { .replace(/</g, "&lt;")
const url = getSmtpUrl(); .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
if (!url) { export function resolveMailFrom(): string {
return (
process.env.SMTP_FROM?.trim() ||
process.env.CLOUDRON_MAIL_FROM?.trim() ||
"noreply@localhost"
);
}
/** Plaintext + HTML for one-time verify URLs. HTML `href` survives quoted-printable wrapping. */
export function buildVerifyLinkParts(
verifyUrl: string,
intro: string,
outro: string,
linkLabel: string,
): { text: string; html: string } {
const text = `${intro}\n\n${verifyUrl}\n\n${outro}`;
const html =
`<p>${escapeHtml(intro).replace(/\n/g, "<br />")}</p>` +
`<p><a href="${escapeHtml(verifyUrl)}">${escapeHtml(linkLabel)}</a></p>` +
`<p>${escapeHtml(outro)}</p>`;
return { text, html };
}
async function sendHtmlMail(opts: {
to: string;
subject: string;
text: string;
html: string;
from?: string;
replyTo?: string;
devLog: string;
}): Promise<void> {
const smtpUrl = getSmtpUrl();
if (!smtpUrl) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`); logger.info(opts.devLog);
return; return;
} }
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
} }
const transporter = nodemailer.createTransport(url); const transporter = nodemailer.createTransport(smtpUrl);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({ await transporter.sendMail({
from, from: opts.from ?? resolveMailFrom(),
to, to: opts.to,
subject: "Sign in to Community Rule", subject: opts.subject,
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`, text: opts.text,
html: opts.html,
replyTo: opts.replyTo,
});
}
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
await sendHtmlMail({
to,
subject: "Sign in to Community Rule",
text,
html,
devLog: `[dev] Magic link for ${to}: ${verifyUrl}`,
}); });
} }
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */ /** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail( export async function sendRuleStakeholderInviteEmail(
to: string, to: string,
verifyUrl: string, verifyUrl: string,
ruleTitle: string, ruleTitle: string,
): Promise<void> { ): Promise<void> {
const url = getSmtpUrl(); const { text, html } = buildVerifyLinkParts(
verifyUrl,
if (!url) { `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:`,
if (process.env.NODE_ENV === "development") { "If you did not expect this, you can ignore this email.",
logger.info( "Open the rule",
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`, );
); await sendHtmlMail({
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
to, to,
subject: `You're invited to view a Community Rule: ${ruleTitle}`, subject: `You're invited to view a Community Rule: ${ruleTitle}`,
text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`, text,
html,
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
}); });
} }
/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */ /** Notify support/organizers when a visitor submits the Ask an organizer form. */
export async function sendOrganizerInquiryNotification(params: { export async function sendOrganizerInquiryNotification(params: {
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */ /** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
to: string; to: string;
@@ -67,26 +113,18 @@ export async function sendOrganizerInquiryNotification(params: {
requestId: string; requestId: string;
}): Promise<void> { }): Promise<void> {
const { to, fromEmail, visitorEmail, message, requestId } = params; const { to, fromEmail, visitorEmail, message, requestId } = params;
const url = getSmtpUrl(); const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`;
const html =
if (!url) { `<p>Request ID: ${escapeHtml(requestId)}<br />From: ${escapeHtml(visitorEmail)}</p>` +
if (process.env.NODE_ENV === "development") { `<pre>${escapeHtml(message)}</pre>`;
logger.info( await sendHtmlMail({
`[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
await transporter.sendMail({
from: fromEmail,
to, to,
from: fromEmail,
replyTo: visitorEmail, replyTo: visitorEmail,
subject: `Ask an organizer inquiry from ${visitorEmail}`, subject: `Ask an organizer inquiry from ${visitorEmail}`,
text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`, text,
html,
devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
}); });
} }
@@ -94,24 +132,17 @@ export async function sendEmailChangeEmail(
to: string, to: string,
verifyUrl: string, verifyUrl: string,
): Promise<void> { ): Promise<void> {
const url = getSmtpUrl(); const { text, html } = buildVerifyLinkParts(
verifyUrl,
if (!url) { "You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):",
if (process.env.NODE_ENV === "development") { "If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`); "Confirm email",
return; );
} await sendHtmlMail({
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
to, to,
subject: "Confirm your new Community Rule email", subject: "Confirm your new Community Rule email",
text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`, text,
html,
devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`,
}); });
} }
+1 -1
View File
@@ -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),
}); });
@@ -4,6 +4,6 @@
"placeholder": "email@domain.com", "placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}", "characterCountTemplate": "{current}/{max}",
"magicLinkSuccessTitle": "Check your email to log in", "magicLinkSuccessTitle": "Check your email to log in",
"magicLinkSuccessDescription": "Your account has been created. A login link has been emailed to you.", "magicLinkSuccessDescription": "We emailed a sign-in link. Open it on this device to continue — check spam or promotions if you don't see it.",
"magicLinkErrorTitle": "Could not send link" "magicLinkErrorTitle": "Could not send link"
} }
+1 -1
View File
@@ -7,7 +7,7 @@
"emailPlaceholder": "you@example.com", "emailPlaceholder": "you@example.com",
"sendMagicLink": "Send me a magic link", "sendMagicLink": "Send me a magic link",
"successTitle": "Check your email", "successTitle": "Check your email",
"successBody": "We sent a sign-in link. Open it on this device to continue.", "successBody": "We sent a sign-in link. Open it on this device to continue. If you don't see it, check spam or promotions.",
"legalPrefix": "By continuing, you agree to our ", "legalPrefix": "By continuing, you agree to our ",
"legalAnd": " and ", "legalAnd": " and ",
"legalSuffix": ".", "legalSuffix": ".",
@@ -6,6 +6,7 @@ import {
cleanup, cleanup,
within, within,
waitFor, waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils"; } from "../utils/test-utils";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
@@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect(labels[1]).toMatch(/Code of Conduct/); expect(labels[1]).toMatch(/Code of Conduct/);
expect(labels[2]).toMatch(/Core Principle/); expect(labels[2]).toMatch(/Core Principle/);
}); });
it("does not block tab close when the create modal is unchanged", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks tab close while the create modal has unsaved field edits", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } });
expect(dispatchBeforeUnload()).toBe(true);
});
it("blocks tab close while the custom-policy wizard has unsaved edits", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* noop */
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
const nameInput = await screen.findByPlaceholderText("Policy name");
expect(dispatchBeforeUnload()).toBe(false);
fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } });
expect(dispatchBeforeUnload()).toBe(true);
});
}); });
+22
View File
@@ -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();
});
}); });
@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach } from "vitest";
import {
renderWithProviders as render,
screen,
cleanup,
within,
} from "../utils/test-utils";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { ConflictManagementScreen } from "../../app/(app)/create/screens/card/ConflictManagementScreen";
afterEach(() => {
cleanup();
});
describe("ConflictManagementScreen", () => {
it("opens section fields editable without Customize", async () => {
render(<ConflictManagementScreen />);
fireEvent.click(
screen.getAllByRole("button", {
name: /Peer Mediation: Trained members/,
})[0],
);
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor, within } from "@testing-library/react"; import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils"; import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils";
import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen"; import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen";
describe("CoreValuesSelectScreen", () => { describe("CoreValuesSelectScreen", () => {
@@ -65,6 +65,20 @@ describe("CoreValuesSelectScreen", () => {
}); });
}); });
it("does not block tab close when a pending value is unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks tab close after editing a pending value", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning");
expect(dispatchBeforeUnload()).toBe(true);
});
it("keeps the pending value modal open when Keep editing is chosen", async () => { it("keeps the pending value modal open when Keep editing is chosen", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import {
renderWithProviders as render,
screen,
fireEvent,
dispatchBeforeUnload,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import CustomMethodCardWizard from "../../app/(app)/create/components/CustomMethodCardWizard";
describe("CustomMethodCardWizard — tab close guard", () => {
it("does not block unload when the wizard is open but unchanged", async () => {
render(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
await screen.findByPlaceholderText("Policy name");
expect(dispatchBeforeUnload()).toBe(false);
});
it("blocks unload after the user types a policy name", async () => {
render(
<CustomMethodCardWizard
isOpen
onClose={() => {
/* noop */
}}
onFinalize={() => {
/* noop */
}}
/>,
);
const name = await screen.findByPlaceholderText("Policy name");
fireEvent.change(name, { target: { value: "Garden hours" } });
expect(dispatchBeforeUnload()).toBe(true);
});
});
+42
View File
@@ -5,6 +5,7 @@ import {
renderWithProviders as render, renderWithProviders as render,
screen, screen,
waitFor, waitFor,
dispatchBeforeUnload,
} from "../utils/test-utils"; } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
@@ -518,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();
});
});
}); });
/** /**
@@ -623,6 +643,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
expect(latest.communicationMethodDetailsById).toBeUndefined(); expect(latest.communicationMethodDetailsById).toBeUndefined();
}); });
it("blocks tab close while chip edits are unsaved", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {
/* noop */
}}
initial={baseSelections}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false);
const principleField = within(dialog).getByRole("textbox", {
name: /core principle/i,
});
fireEvent.change(principleField, {
target: { value: "Unsaved on tab close" },
});
expect(dispatchBeforeUnload()).toBe(true);
});
it("shows consolidated placeholder for user-authored communication chips", async () => { it("shows consolidated placeholder for user-authored communication chips", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000"; const customId = "550e8400-e29b-41d4-a716-446655440000";
render( render(
+1
View File
@@ -125,6 +125,7 @@ describe("LoginForm", () => {
await screen.findByRole("heading", { name: /check your email/i }), await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument();
}); });
it("submits a long email without treating length as invalid", async () => { it("submits a long email without treating length as invalid", async () => {
@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach } from "vitest";
import {
renderWithProviders as render,
screen,
cleanup,
within,
} from "../utils/test-utils";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { MembershipMethodsScreen } from "../../app/(app)/create/screens/card/MembershipMethodsScreen";
afterEach(() => {
cleanup();
});
describe("MembershipMethodsScreen", () => {
it("opens section fields editable without Customize", async () => {
render(<MembershipMethodsScreen />);
fireEvent.click(
screen.getAllByRole("button", {
name: /Open Access: Maximum inclusion/,
})[0],
);
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
});
});
+13
View File
@@ -42,4 +42,17 @@ describe("TextArea appearance", () => {
expect(textarea).toBeInTheDocument(); expect(textarea).toBeInTheDocument();
expect(textarea).toHaveClass("border-0"); expect(textarea).toHaveClass("border-0");
}); });
it("uses tertiary text in the embedded default state and primary on focus", () => {
renderWithProviders(
<TextArea label="Notes" value="Some text" appearance="embedded" />,
);
const textarea = screen.getByRole("textbox", { name: /notes/i });
expect(textarea).toHaveClass(
"text-[var(--color-content-default-tertiary,#b4b4b4)]",
);
expect(textarea).toHaveClass(
"focus:text-[var(--color-content-default-primary)]",
);
});
}); });
+13
View File
@@ -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();
});
}); });
@@ -4,6 +4,7 @@ import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession, confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty, isMethodCardCustomizeSessionDirty,
isMethodCardCustomizeUnloadBlocked,
} from "../../lib/create/methodCardCustomizeSession"; } from "../../lib/create/methodCardCustomizeSession";
const HEADER_0 = { title: "", description: "" }; const HEADER_0 = { title: "", description: "" };
@@ -88,4 +89,24 @@ describe("methodCardCustomizeSession", () => {
).toBe(false); ).toBe(false);
expect(confirmFn).toHaveBeenCalled(); expect(confirmFn).toHaveBeenCalled();
}); });
it("unload block is false when the modal is closed or snapshot is missing", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0),
).toBe(false);
});
it("unload block is true only when the open modal is dirty", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0),
).toBe(true);
});
}); });
@@ -126,6 +126,33 @@ describe("Create flow communication-methods page", () => {
expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument();
}); });
test("meta-only custom policy opens with editable section fields", async () => {
const user = userEvent.setup();
render(
<CommunicationMethodsScreenWithState
initial={{
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Desc" },
},
}}
/>,
);
await user.click(
screen.getAllByRole("button", { name: /My policy: Desc/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) {
expect(field).not.toBeDisabled();
}
expect(
within(dialog).queryByText("No custom fields yet."),
).not.toBeInTheDocument();
});
test("renders without error", () => { test("renders without error", () => {
render(<CommunicationMethodsScreen />); render(<CommunicationMethodsScreen />);
+23
View File
@@ -201,6 +201,29 @@ describe("Create flow decision-approaches page", () => {
).toHaveTextContent("SELECTED"); ).toHaveTextContent("SELECTED");
}); });
test("opens approach fields editable without Customize", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) {
expect(field).not.toBeDisabled();
}
await user.click(
within(dialog).getByRole("button", { name: "More options" }),
);
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
});
test("clicking a card opens the create modal and confirming selects it", async () => { test("clicking a card opens the create modal and confirming selects it", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
+49
View File
@@ -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",
@@ -0,0 +1,26 @@
import { renderHook } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useBeforeUnloadGuard } from "../../../app/hooks/useBeforeUnloadGuard";
import { dispatchBeforeUnload } from "../../utils/test-utils";
describe("useBeforeUnloadGuard", () => {
it("does not intercept unload when disabled", () => {
renderHook(() => useBeforeUnloadGuard(false));
expect(dispatchBeforeUnload()).toBe(false);
});
it("prevents unload when enabled", () => {
renderHook(() => useBeforeUnloadGuard(true));
expect(dispatchBeforeUnload()).toBe(true);
});
it("drops the listener when enabled flips to false", () => {
const { rerender } = renderHook(
({ enabled }) => useBeforeUnloadGuard(enabled),
{ initialProps: { enabled: true } },
);
expect(dispatchBeforeUnload()).toBe(true);
rerender({ enabled: false });
expect(dispatchBeforeUnload()).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from "vitest";
import nodemailer from "nodemailer";
import {
buildVerifyLinkParts,
resolveMailFrom,
} from "../../lib/server/mail";
const VERIFY_URL =
"https://staging.communityrule.info/api/auth/magic-link/verify?token=5IdE_BHowaw-QJj7Rwue7CbB8wDXvYITvnxRb1FGqxA";
function decodeQuotedPrintable(value: string): string {
return value
.replace(/=\r?\n/g, "")
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
}
const MAIL_FROM_KEYS = ["SMTP_FROM", "CLOUDRON_MAIL_FROM"] as const;
const ORIGINAL_FROM = Object.fromEntries(
MAIL_FROM_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof MAIL_FROM_KEYS)[number], string | undefined>;
afterEach(() => {
for (const key of MAIL_FROM_KEYS) {
const original = ORIGINAL_FROM[key];
if (original === undefined) delete process.env[key];
else process.env[key] = original;
}
});
describe("buildVerifyLinkParts", () => {
it("puts the exact verify URL in both text and the HTML href", () => {
const { text, html } = buildVerifyLinkParts(
VERIFY_URL,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
expect(text).toContain(VERIFY_URL);
expect(html).toContain(`href="${VERIFY_URL}"`);
expect(html).toContain(">Sign in</a>");
});
it("escapes HTML in the intro and href", () => {
const { html } = buildVerifyLinkParts(
'https://example.test/verify?token=a&b="c"',
'View "Rule <beta>"',
"Ignore if unexpected.",
"Open",
);
expect(html).toContain("View &quot;Rule &lt;beta&gt;&quot;");
expect(html).toContain(
'href="https://example.test/verify?token=a&amp;b=&quot;c&quot;"',
);
});
});
describe("MIME encoding of verify-link mail", () => {
it("keeps a clickable href after quoted-printable encoding", async () => {
const { text, html } = buildVerifyLinkParts(
VERIFY_URL,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
const transporter = nodemailer.createTransport({
streamTransport: true,
buffer: true,
newline: "unix",
});
const info = await transporter.sendMail({
from: "Community Rule <staging.app@communityrule.info>",
to: "member@example.com",
subject: "Sign in to Community Rule",
text,
html,
});
const raw = Buffer.isBuffer(info.message)
? info.message.toString("utf8")
: String(info.message);
const decoded = decodeQuotedPrintable(raw);
expect(decoded).toContain(`href="${VERIFY_URL}"`);
expect(decoded).toContain(VERIFY_URL);
expect(decoded).not.toContain("token=3D");
});
});
describe("resolveMailFrom", () => {
it("prefers SMTP_FROM, then CLOUDRON_MAIL_FROM", () => {
delete process.env.SMTP_FROM;
delete process.env.CLOUDRON_MAIL_FROM;
expect(resolveMailFrom()).toBe("noreply@localhost");
process.env.CLOUDRON_MAIL_FROM = "staging.app@communityrule.info";
expect(resolveMailFrom()).toBe("staging.app@communityrule.info");
process.env.SMTP_FROM = "Community Rule <hello@communityrule.info>";
expect(resolveMailFrom()).toBe(
"Community Rule <hello@communityrule.info>",
);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
} from "../../lib/create/methodCardCustomizeSession";
const HEADER_0 = { title: "", description: "" };
describe("isMethodCardCustomizeUnloadBlocked", () => {
it("is false when the modal is closed or snapshot is missing", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(false, snap, { x: 2 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, null, { x: 2 }, null, HEADER_0),
).toBe(false);
});
it("is true only when the open modal is dirty", () => {
const snap = captureMethodCardCustomizeSnapshot({ x: 1 }, null, HEADER_0);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 1 }, null, HEADER_0),
).toBe(false);
expect(
isMethodCardCustomizeUnloadBlocked(true, snap, { x: 2 }, null, HEADER_0),
).toBe(true);
});
});
@@ -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.");
});
});
+7
View File
@@ -25,5 +25,12 @@ export function renderWithProviders(
return render(ui, { wrapper: Wrapper, ...options }); return render(ui, { wrapper: Wrapper, ...options });
} }
/** True when a `beforeunload` listener called `preventDefault` (tab-close guard). */
export function dispatchBeforeUnload(): boolean {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
// Re-export everything from @testing-library/react for convenience // Re-export everything from @testing-library/react for convenience
export * from "@testing-library/react"; export * from "@testing-library/react";