Compare commits

..
5 Commits
Author SHA1 Message Date
an.di fa928d1b2c 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
2026-09-01 16:09:31 +00:00
adilalloandCursor d920e39f09 Send sign-in mail as HTML so quoted-printable wrapping cannot break the verify URL.
Staging was delivering magic links that looked expired because the token query string was encoded and wrapped in plaintext MIME. Also read rate-limit retry from the API error body, mention spam in the success copy, and document SES relay DNS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:01:25 -06:00
adilalloandCursor 511c3efb4c Stop seeding 75% consensus on custom decision cards so wizard copy shows on the published rule.
Wire Back on the final-review chip editor to the same dismiss path as close, instead of a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:28:32 -06:00
adilalloandCursor 7f67cc6271 Warn on tab close when create-flow module or wizard edits have not been saved.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:00:40 -06:00
adilalloandCursor a820739d07 Open create-flow method modals with editable fields and tertiary default copy.
Seeded section text should look like the form default state, not locked primary, and Customize stays on the kebab instead of unlocking the body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:59:53 -06: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";