Fix magic-link verify URLs in email and related create-flow QA #70
@@ -4,7 +4,7 @@
|
||||
"title": "Community Rule",
|
||||
"author": "MEDLab",
|
||||
"description": "Community governance and rule-building app",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"httpPort": 3000,
|
||||
"healthCheckPath": "/api/health",
|
||||
"memoryLimit": 805306368,
|
||||
|
||||
+3
@@ -6,6 +6,7 @@ import {
|
||||
useTranslation,
|
||||
} from "../../../../contexts/MessagesContext";
|
||||
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import {
|
||||
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||
@@ -213,6 +214,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
policyTitle,
|
||||
]);
|
||||
|
||||
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
|
||||
|
||||
const confirmAbandonWizardEdits = useCallback(async () => {
|
||||
if (!isWizardSessionDirty()) {
|
||||
return true;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
|
||||
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
|
||||
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
|
||||
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
@@ -370,6 +371,12 @@ export function FinalReviewChipEditModal({
|
||||
draftFieldBlocks,
|
||||
]);
|
||||
|
||||
useBeforeUnloadGuard(
|
||||
isOpen &&
|
||||
!addCustomWizardOpen &&
|
||||
(!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled),
|
||||
);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = Boolean(
|
||||
target &&
|
||||
(usesWizardFieldBlocksModalBody({
|
||||
@@ -1071,6 +1078,7 @@ export function FinalReviewChipEditModal({
|
||||
<Create
|
||||
isOpen={isOpen && !addCustomWizardOpen}
|
||||
onClose={handleModalClose}
|
||||
onBack={handleModalClose}
|
||||
backdropVariant="blurredYellow"
|
||||
headerContent={headerContent}
|
||||
showNextButton={true}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
|
||||
[draft],
|
||||
);
|
||||
|
||||
useBeforeUnloadGuard(isOpen && isDirty);
|
||||
|
||||
const characterHint = tField("characterCountTemplate")
|
||||
.replace("{current}", String(draft.length))
|
||||
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { CoreValueDetailEntry } from "../../types";
|
||||
export interface CoreValueEditFieldsProps {
|
||||
value: CoreValueDetailEntry;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function DecisionApproachEditFieldsComponent({
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{value.consensusLevel !== undefined ? (
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
helpIcon={false}
|
||||
@@ -93,6 +94,7 @@ function DecisionApproachEditFieldsComponent({
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : null}
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.objectionsDeadlocks}
|
||||
value={value.objectionsDeadlocks}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
*
|
||||
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
|
||||
* action is **Add Platform** for an unselected card; a selected card in view mode has
|
||||
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
|
||||
* action is **Add Platform** for an unselected card and **Save** for a selected
|
||||
* card. **Remove** is available from the kebab (same behavior as legacy
|
||||
* footer remove via {@link removeMethodCardFromFacetSelection}).
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
@@ -57,6 +58,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
|
||||
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
isMethodCardCustomizeUnloadBlocked,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -88,6 +90,16 @@ export function CommunicationMethodsScreen() {
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
|
||||
useBeforeUnloadGuard(
|
||||
isMethodCardCustomizeUnloadBlocked(
|
||||
createModalOpen,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
const mergedMethods = useMemo(
|
||||
@@ -216,7 +228,7 @@ export function CommunicationMethodsScreen() {
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked: false,
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
@@ -54,6 +55,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
|
||||
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
isMethodCardCustomizeUnloadBlocked,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -85,6 +87,16 @@ export function ConflictManagementScreen() {
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
|
||||
useBeforeUnloadGuard(
|
||||
isMethodCardCustomizeUnloadBlocked(
|
||||
createModalOpen,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
const mergedMethods = useMemo(
|
||||
@@ -217,7 +229,7 @@ export function ConflictManagementScreen() {
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked: false,
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
@@ -55,6 +56,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
|
||||
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
isMethodCardCustomizeUnloadBlocked,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -86,6 +88,16 @@ export function MembershipMethodsScreen() {
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
|
||||
useBeforeUnloadGuard(
|
||||
isMethodCardCustomizeUnloadBlocked(
|
||||
createModalOpen,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
const mergedMethods = useMemo(
|
||||
@@ -214,7 +226,7 @@ export function MembershipMethodsScreen() {
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked: false,
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
@@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me
|
||||
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
isMethodCardCustomizeUnloadBlocked,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -91,6 +93,16 @@ export function DecisionApproachesScreen() {
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
|
||||
useBeforeUnloadGuard(
|
||||
isMethodCardCustomizeUnloadBlocked(
|
||||
createModalOpen,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
|
||||
detailsById: state.decisionApproachDetailsById,
|
||||
@@ -250,7 +262,7 @@ export function DecisionApproachesScreen() {
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked: false,
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CoreValueDetailEntry,
|
||||
@@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() {
|
||||
});
|
||||
}, [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(() => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
markCreateFlowInteraction();
|
||||
|
||||
@@ -85,7 +85,11 @@ export type DecisionApproachDetailEntry = {
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
stepByStepInstructions: string;
|
||||
consensusLevel: number;
|
||||
/**
|
||||
* Catalog presets always set this. User-authored custom cards omit it until
|
||||
* the author adds a proportion field or edits consensus in the facet form.
|
||||
*/
|
||||
consensusLevel?: number;
|
||||
objectionsDeadlocks: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (): {
|
||||
textarea: string;
|
||||
label: string;
|
||||
@@ -89,13 +90,13 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
if (disabled) {
|
||||
return {
|
||||
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)]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
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)]",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export interface TextAreaProps extends Omit<
|
||||
showHelpIcon?: boolean;
|
||||
/**
|
||||
* 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"
|
||||
*/
|
||||
appearance?: TextAreaAppearanceValue;
|
||||
|
||||
@@ -14,8 +14,8 @@ export interface CommunityRuleEntry {
|
||||
/** Plain text; split on blank lines into paragraphs when rendering. */
|
||||
body: string;
|
||||
/**
|
||||
* When set, rendered as Figma-style label + body stacks. If non-empty, takes
|
||||
* precedence over {@link body} for main content (body may be empty).
|
||||
* Figma-style label + body stacks (facet sections, wizard fields). Shown
|
||||
* after {@link body} when both are present.
|
||||
*/
|
||||
blocks?: CommunityRuleLabeledBlock[];
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ function CommunityRuleView({
|
||||
<TextBlock
|
||||
key={entryIndex}
|
||||
title={entry.title}
|
||||
body={hasBlocks ? undefined : entry.body}
|
||||
body={entry.body}
|
||||
rows={hasBlocks ? entry.blocks : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -94,9 +94,10 @@ function TextBlockView({
|
||||
>
|
||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
|
||||
{hasRows
|
||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export { useMediaQuery,
|
||||
} from "./useMediaQuery";
|
||||
export { useAsyncConfirm } from "./useAsyncConfirm";
|
||||
export type { AsyncConfirmOptions } from "./useAsyncConfirm";
|
||||
export { useBeforeUnloadGuard } from "./useBeforeUnloadGuard";
|
||||
export type {
|
||||
SchemaOrganization,
|
||||
SchemaWebSite,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Attach the browser’s 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]);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ npm run dev
|
||||
|
||||
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._
|
||||
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`._
|
||||
5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._
|
||||
|
||||
|
||||
@@ -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
|
||||
(legacy app shows weekly snapshots ~451 MB each). Same default
|
||||
applies to new apps.
|
||||
- TLS / DNS / SPF / DKIM: handled by Cloudron for any subdomain of
|
||||
`communityrule.info`.
|
||||
- TLS for Cloudron app hostnames: handled by Cloudron (Let's Encrypt).
|
||||
- **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)
|
||||
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
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
|
||||
|
||||
- [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover
|
||||
|
||||
+23
-36
@@ -27,6 +27,26 @@ function readApiErrorMessage(data: unknown): string {
|
||||
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<{
|
||||
user: { id: string; email: string } | null;
|
||||
}> {
|
||||
@@ -54,13 +74,12 @@ export async function requestMagicLink(
|
||||
...(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) {
|
||||
return {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs:
|
||||
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -85,22 +104,10 @@ export async function requestEmailChange(
|
||||
});
|
||||
const data: unknown = await res.json().catch(() => ({}));
|
||||
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 {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -438,26 +445,6 @@ export type RuleStakeholderMutationResult =
|
||||
| { ok: true }
|
||||
| { 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(
|
||||
ruleId: string,
|
||||
email: string,
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
publishedMethodDisplayLabel,
|
||||
} from "./finalReviewChipPresets";
|
||||
import { isDocumentEntry } from "./documentEntryGuards";
|
||||
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
|
||||
import {
|
||||
replaceMethodSectionsWithMethodSelections,
|
||||
withoutUnpublishedDecisionConsensus,
|
||||
} from "./ruleSectionsFromMethodSelections";
|
||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||
|
||||
export { isDocumentEntry } from "./documentEntryGuards";
|
||||
@@ -78,21 +81,25 @@ export type PublishedMethodSelections = {
|
||||
id: string;
|
||||
label: string;
|
||||
sections: CommunicationMethodDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
membership?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: MembershipMethodDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
decisionApproaches?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: DecisionApproachDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
conflictManagement?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: ConflictManagementDetailEntry;
|
||||
supportText?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -247,6 +254,14 @@ function pickMethodIds(
|
||||
return derived;
|
||||
}
|
||||
|
||||
function publishedRowSupportText(
|
||||
id: string,
|
||||
meta: CreateFlowState["customMethodCardMetaById"],
|
||||
): string | undefined {
|
||||
const t = meta?.[id]?.supportText?.trim();
|
||||
return t && t.length > 0 ? t : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||
* overrides authored on the final-review screen. Preset defaults from the
|
||||
@@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument(
|
||||
out.communication = commIds.map((id) => {
|
||||
const preset = communicationPresetFor(id);
|
||||
const override = state.communicationMethodDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument(
|
||||
out.membership = memIds.map((id) => {
|
||||
const preset = membershipPresetFor(id);
|
||||
const override = state.membershipMethodDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument(
|
||||
out.decisionApproaches = daIds.map((id) => {
|
||||
const preset = decisionApproachPresetFor(id);
|
||||
const override = state.decisionApproachDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
const merged = override ? { ...preset, ...override } : preset;
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
sections: withoutUnpublishedDecisionConsensus(
|
||||
{ ...merged },
|
||||
state.customMethodCardFieldBlocksById?.[id],
|
||||
) as DecisionApproachDetailEntry,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -330,6 +364,10 @@ export function buildMethodSelectionsForDocument(
|
||||
out.conflictManagement = cmIds.map((id) => {
|
||||
const preset = conflictManagementPresetFor(id);
|
||||
const override = state.conflictManagementDetailsById?.[id];
|
||||
const supportText = publishedRowSupportText(
|
||||
id,
|
||||
state.customMethodCardMetaById,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
label: publishedMethodDisplayLabel(
|
||||
@@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument(
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
...(supportText ? { supportText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry {
|
||||
};
|
||||
}
|
||||
|
||||
/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */
|
||||
/** Default consensus level used when a **catalog** preset omits a value. */
|
||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
export function decisionApproachPresetFor(
|
||||
@@ -114,19 +114,22 @@ export function decisionApproachPresetFor(
|
||||
): DecisionApproachDetailEntry {
|
||||
const method = findMethod(decisionApproachesMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
const entry: DecisionApproachDetailEntry = {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
applicableScope: asStringArray(s.applicableScope),
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||
consensusLevel: asNumberClamped(
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
if (method) {
|
||||
entry.consensusLevel = asNumberClamped(
|
||||
s.consensusLevel,
|
||||
0,
|
||||
100,
|
||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||
),
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function conflictManagementPresetFor(
|
||||
|
||||
@@ -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>(
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft>,
|
||||
pendingDraft: TDraft | null,
|
||||
|
||||
@@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks(
|
||||
prefill.headings.stepByStepInstructions,
|
||||
prefill.draft.stepByStepInstructions,
|
||||
),
|
||||
{
|
||||
);
|
||||
if (
|
||||
typeof prefill.draft.consensusLevel === "number" &&
|
||||
facetPrefillHasContent(prefill)
|
||||
) {
|
||||
blocks.push({
|
||||
kind: "proportion",
|
||||
id: "facet-consensusLevel",
|
||||
blockTitle: prefill.headings.consensusLevel,
|
||||
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
||||
},
|
||||
});
|
||||
}
|
||||
blocks.push(
|
||||
textBlock(
|
||||
"facet-objectionsDeadlocks",
|
||||
prefill.headings.objectionsDeadlocks,
|
||||
|
||||
@@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections(
|
||||
| Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
supportText?: string;
|
||||
}>
|
||||
| undefined,
|
||||
) => {
|
||||
@@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections(
|
||||
if (methodLabelFor(groupKey, id).length > 0) continue;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
if (!label) continue;
|
||||
meta[id] = { label, supportText: "" };
|
||||
const supportText =
|
||||
typeof row.supportText === "string" ? row.supportText : "";
|
||||
meta[id] = { label, supportText };
|
||||
}
|
||||
};
|
||||
absorb("communication", ms.communication);
|
||||
|
||||
@@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
||||
export type CommunityRuleEntryFromChipOptions = {
|
||||
consensusLevelKey?: string;
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||
/** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */
|
||||
supportText?: string;
|
||||
};
|
||||
|
||||
/** Canonical `categoryName` strings for method groups in published documents. */
|
||||
@@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip(
|
||||
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
||||
: [];
|
||||
const blocks = [...presetBlocks, ...wizardBlocks];
|
||||
if (blocks.length === 0) return null;
|
||||
return { title, body: "", blocks };
|
||||
const description = nonEmptyTrimmed(options?.supportText);
|
||||
if (blocks.length === 0) {
|
||||
if (!description) return null;
|
||||
return { title, body: description };
|
||||
}
|
||||
return {
|
||||
title,
|
||||
body: description ?? "",
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionApproachHasPublishableFacetCopy(
|
||||
sections: Record<string, unknown>,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
nonEmptyTrimmed(sections.corePrinciple) ||
|
||||
nonEmptyTrimmed(sections.stepByStepInstructions) ||
|
||||
nonEmptyTrimmed(sections.objectionsDeadlocks) ||
|
||||
formatScopePayload(sections.applicableScope) ||
|
||||
formatScopePayload(sections.selectedApplicableScope),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog methods publish their consensus figure. User-authored custom cards
|
||||
* often seed `75` with empty facet copy — skip that unless the author actually
|
||||
* filled decision sections. Wizard field blocks (including proportion) are the
|
||||
* source of truth when present.
|
||||
*/
|
||||
function shouldPublishDecisionConsensusLevel(
|
||||
sections: Record<string, unknown>,
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||
): boolean {
|
||||
if (
|
||||
typeof sections.consensusLevel !== "number" ||
|
||||
Number.isNaN(sections.consensusLevel)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (customFieldBlocks && customFieldBlocks.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return decisionApproachHasPublishableFacetCopy(sections);
|
||||
}
|
||||
|
||||
/** Drop seeded / wizard-superseded `consensusLevel` before publish or hydrate. */
|
||||
export function withoutUnpublishedDecisionConsensus(
|
||||
sections: Record<string, unknown>,
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||
): Record<string, unknown> {
|
||||
if (shouldPublishDecisionConsensusLevel(sections, customFieldBlocks)) {
|
||||
return sections;
|
||||
}
|
||||
if (!("consensusLevel" in sections)) return sections;
|
||||
const next = { ...sections };
|
||||
delete next.consensusLevel;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sectionFromCommunication(
|
||||
@@ -209,6 +267,7 @@ export function sectionFromCommunication(
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
@@ -227,6 +286,7 @@ export function sectionFromMembership(
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
@@ -254,10 +314,19 @@ export function sectionFromDecision(
|
||||
);
|
||||
if (scope) merged.applicableScope = scope;
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
|
||||
const e = communityRuleEntryFromMethodChip(
|
||||
m.label,
|
||||
withoutUnpublishedDecisionConsensus(
|
||||
merged,
|
||||
customFieldBlocksById?.[m.id],
|
||||
),
|
||||
DEC_LABELS,
|
||||
{
|
||||
consensusLevelKey: "consensusLevel",
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
supportText: m.supportText,
|
||||
},
|
||||
);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
return entries.length > 0
|
||||
@@ -281,6 +350,7 @@ export function sectionFromConflict(
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId";
|
||||
* so meta-only wizard cards show policy copy instead of empty section editors.
|
||||
* Pass `customFacetDetailsMatchPreset: false` when the caller knows facet details
|
||||
* 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: {
|
||||
methodId: string;
|
||||
|
||||
+98
-67
@@ -2,62 +2,108 @@ import nodemailer from "nodemailer";
|
||||
import { logger } from "../logger";
|
||||
import { getSmtpUrl } from "./env";
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = getSmtpUrl();
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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") {
|
||||
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
|
||||
logger.info(opts.devLog);
|
||||
return;
|
||||
}
|
||||
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||
|
||||
const transporter = nodemailer.createTransport(smtpUrl);
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: "Sign in to Community Rule",
|
||||
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.`,
|
||||
from: opts.from ?? resolveMailFrom(),
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
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). */
|
||||
export async function sendRuleStakeholderInviteEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
ruleTitle: string,
|
||||
): Promise<void> {
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(
|
||||
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
verifyUrl,
|
||||
`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 you did not expect this, you can ignore this email.",
|
||||
"Open the rule",
|
||||
);
|
||||
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,
|
||||
await sendHtmlMail({
|
||||
to,
|
||||
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: {
|
||||
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
|
||||
to: string;
|
||||
@@ -67,26 +113,18 @@ export async function sendOrganizerInquiryNotification(params: {
|
||||
requestId: string;
|
||||
}): Promise<void> {
|
||||
const { to, fromEmail, visitorEmail, message, requestId } = params;
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(
|
||||
`[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,
|
||||
const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`;
|
||||
const html =
|
||||
`<p>Request ID: ${escapeHtml(requestId)}<br />From: ${escapeHtml(visitorEmail)}</p>` +
|
||||
`<pre>${escapeHtml(message)}</pre>`;
|
||||
await sendHtmlMail({
|
||||
to,
|
||||
from: fromEmail,
|
||||
replyTo: 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,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`);
|
||||
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,
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
verifyUrl,
|
||||
"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 you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
|
||||
"Confirm email",
|
||||
);
|
||||
await sendHtmlMail({
|
||||
to,
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const decisionApproachDetailEntrySchema = z.object({
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
stepByStepInstructions: z.string().max(8000),
|
||||
consensusLevel: z.number().int().min(0).max(100),
|
||||
consensusLevel: z.number().int().min(0).max(100).optional(),
|
||||
objectionsDeadlocks: z.string().max(8000),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"placeholder": "email@domain.com",
|
||||
"characterCountTemplate": "{current}/{max}",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"sendMagicLink": "Send me a magic link",
|
||||
"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 ",
|
||||
"legalAnd": " and ",
|
||||
"legalSuffix": ".",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
cleanup,
|
||||
within,
|
||||
waitFor,
|
||||
dispatchBeforeUnload,
|
||||
} from "../utils/test-utils";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
expect(labels[1]).toMatch(/Code of Conduct/);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,4 +50,26 @@ describe("CommunityRule", () => {
|
||||
);
|
||||
expect(screen.getByText("How proposals pass")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders entry body together with labeled blocks", () => {
|
||||
render(
|
||||
<CommunityRule
|
||||
sections={[
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Forum proposals",
|
||||
body: "Anyone can start a thread.",
|
||||
blocks: [{ label: "Quorum", body: "60%" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
||||
expect(screen.getByText("60%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
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 () => {
|
||||
renderWithProviders(<CoreValuesSelectScreen />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
waitFor,
|
||||
dispatchBeforeUnload,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
|
||||
@@ -518,6 +519,25 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the chip edit modal when Back is pressed", async () => {
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={() => {}}
|
||||
initial={{
|
||||
title: "Oak Park Commons",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Back" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -623,6 +643,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
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 () => {
|
||||
const customId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
render(
|
||||
|
||||
@@ -125,6 +125,7 @@ describe("LoginForm", () => {
|
||||
await screen.findByRole("heading", { name: /check your email/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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -42,4 +42,17 @@ describe("TextArea appearance", () => {
|
||||
expect(textarea).toBeInTheDocument();
|
||||
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)]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,4 +45,17 @@ describe("TextBlock", () => {
|
||||
"/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders body paragraphs above labeled rows when both are set", () => {
|
||||
render(
|
||||
<TextBlock
|
||||
title="Forum proposals"
|
||||
body="Anyone can start a thread."
|
||||
rows={[{ label: "Quorum", body: "60%" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
||||
expect(screen.getByText("60%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
isMethodCardCustomizeUnloadBlocked,
|
||||
} from "../../lib/create/methodCardCustomizeSession";
|
||||
|
||||
const HEADER_0 = { title: "", description: "" };
|
||||
@@ -88,4 +89,24 @@ describe("methodCardCustomizeSession", () => {
|
||||
).toBe(false);
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
|
||||
@@ -201,6 +201,29 @@ describe("Create flow decision-approaches page", () => {
|
||||
).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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -249,6 +249,55 @@ describe("buildPublishPayload — methodSelections", () => {
|
||||
expect(entry.sections.corePrinciple.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not seed 75% consensus on a custom decision-making card", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000170";
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedDecisionApproachIds: [customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: {
|
||||
label: "Forum proposals",
|
||||
supportText: "Anyone can start a thread.",
|
||||
},
|
||||
},
|
||||
decisionApproachDetailsById: {
|
||||
[customId]: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 75,
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const ms = r.document.methodSelections as {
|
||||
decisionApproaches?: Array<{
|
||||
supportText?: string;
|
||||
sections: { consensusLevel?: number };
|
||||
}>;
|
||||
};
|
||||
expect(ms.decisionApproaches?.[0]?.supportText).toBe(
|
||||
"Anyone can start a thread.",
|
||||
);
|
||||
expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps catalog decision consensus in methodSelections", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const ms = r.document.methodSelections as {
|
||||
decisionApproaches?: Array<{ sections: { consensusLevel?: number } }>;
|
||||
};
|
||||
expect(ms.decisionApproaches?.[0]?.sections.consensusLevel).toBe(100);
|
||||
});
|
||||
|
||||
it("merges override on top of preset for the selected method", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 "Rule <beta>"");
|
||||
expect(html).toContain(
|
||||
'href="https://example.test/verify?token=a&b="c""',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
coreValueDetailsFromWizardFieldBlocks,
|
||||
facetDetailsToWizardFieldBlocks,
|
||||
overlayFacetPrefillValues,
|
||||
} from "../../lib/create/methodCardWizardPrefill";
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
@@ -219,6 +220,49 @@ describe("facetDetailsToWizardFieldBlocks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a consensus proportion when the draft has no consensus level", () => {
|
||||
const blocksOut = facetDetailsToWizardFieldBlocks({
|
||||
group: "decisionApproaches",
|
||||
draft: {
|
||||
corePrinciple: "Momentum.",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "Post a deadline.",
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
headings: {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
stepByStepInstructions: "Step-by-Step Instructions",
|
||||
consensusLevel: "Consensus Level",
|
||||
objectionsDeadlocks: "Objections & Deadlocks",
|
||||
},
|
||||
});
|
||||
expect(blocksOut.find((b) => b.kind === "proportion")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inject a seeded 75% proportion onto empty custom drafts", () => {
|
||||
const next = overlayFacetPrefillValues([], {
|
||||
group: "decisionApproaches",
|
||||
draft: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 75,
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
headings: {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
stepByStepInstructions: "Step-by-Step Instructions",
|
||||
consensusLevel: "Consensus Level",
|
||||
objectionsDeadlocks: "Objections & Deadlocks",
|
||||
},
|
||||
});
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps core value meaning and signals onto text blocks", () => {
|
||||
expect(
|
||||
facetDetailsToWizardFieldBlocks({
|
||||
|
||||
@@ -314,6 +314,37 @@ describe("createFlowStateFromPublishedRule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates wizard supportText from published methodSelections", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000002";
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-custom-desc",
|
||||
title: "C",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
decisionApproaches: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Forum proposals",
|
||||
supportText: "Anyone can start a thread.",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(partial.customMethodCardMetaById?.[customId]).toEqual({
|
||||
label: "Forum proposals",
|
||||
supportText: "Anyone can start a thread.",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-2",
|
||||
|
||||
@@ -248,4 +248,35 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows custom decision description without a seeded 75% consensus row", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000170";
|
||||
const out = parsePublishedDocumentForCommunityRuleDisplay({
|
||||
sections: [],
|
||||
methodSelections: {
|
||||
decisionApproaches: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Forum proposals",
|
||||
supportText: "Anyone can start a thread.",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 75,
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const decision = out.find((s) => s.categoryName === "Decision-making");
|
||||
expect(decision?.entries).toEqual([
|
||||
{
|
||||
title: "Forum proposals",
|
||||
body: "Anyone can start a thread.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
communityRuleEntryFromMethodChip,
|
||||
sectionFromDecision,
|
||||
withoutUnpublishedDecisionConsensus,
|
||||
} from "../../lib/create/ruleSectionsFromMethodSelections";
|
||||
|
||||
const emptyDecisionSections = {
|
||||
corePrinciple: "",
|
||||
applicableScope: [] as string[],
|
||||
selectedApplicableScope: [] as string[],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 75,
|
||||
objectionsDeadlocks: "",
|
||||
};
|
||||
|
||||
describe("withoutUnpublishedDecisionConsensus", () => {
|
||||
it("keeps catalog consensus when facet copy is present", () => {
|
||||
const next = withoutUnpublishedDecisionConsensus({
|
||||
corePrinciple: "Momentum.",
|
||||
consensusLevel: 100,
|
||||
});
|
||||
expect(next.consensusLevel).toBe(100);
|
||||
});
|
||||
|
||||
it("drops seeded consensus on empty custom facet copy", () => {
|
||||
const next = withoutUnpublishedDecisionConsensus({ ...emptyDecisionSections });
|
||||
expect(next.consensusLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops keyed consensus when wizard field blocks exist", () => {
|
||||
const next = withoutUnpublishedDecisionConsensus(
|
||||
{ corePrinciple: "Momentum.", consensusLevel: 75 },
|
||||
[
|
||||
{
|
||||
kind: "proportion",
|
||||
id: "p1",
|
||||
blockTitle: "Quorum",
|
||||
defaultPercent: 60,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(next.consensusLevel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("communityRuleEntryFromMethodChip", () => {
|
||||
it("publishes supportText as body when there are no labeled blocks", () => {
|
||||
expect(
|
||||
communityRuleEntryFromMethodChip(
|
||||
"Our process",
|
||||
{ corePrinciple: "" },
|
||||
{ corePrinciple: "Core Principle" },
|
||||
{ supportText: " Members propose in the forum. " },
|
||||
),
|
||||
).toEqual({
|
||||
title: "Our process",
|
||||
body: "Members propose in the forum.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps supportText alongside wizard blocks", () => {
|
||||
const entry = communityRuleEntryFromMethodChip(
|
||||
"Our process",
|
||||
{ corePrinciple: "" },
|
||||
{ corePrinciple: "Core Principle" },
|
||||
{
|
||||
supportText: "How we decide.",
|
||||
customFieldBlocks: [
|
||||
{
|
||||
kind: "text",
|
||||
id: "b1",
|
||||
blockTitle: "Notes",
|
||||
placeholderText: "Post in #governance.",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(entry).toEqual({
|
||||
title: "Our process",
|
||||
body: "How we decide.",
|
||||
blocks: [{ label: "Notes", body: "Post in #governance." }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionFromDecision", () => {
|
||||
it("omits seeded 75% and shows the policy description on a custom card", () => {
|
||||
const section = sectionFromDecision([
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
label: "Forum proposals",
|
||||
supportText: "Anyone can start a thread; silence after a week is consent.",
|
||||
sections: { ...emptyDecisionSections },
|
||||
},
|
||||
]);
|
||||
expect(section?.categoryName).toBe("Decision-making");
|
||||
expect(section?.entries).toEqual([
|
||||
{
|
||||
title: "Forum proposals",
|
||||
body: "Anyone can start a thread; silence after a week is consent.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("still emits catalog consensus when facet copy is present", () => {
|
||||
const section = sectionFromDecision([
|
||||
{
|
||||
id: "lazy-consensus",
|
||||
label: "Lazy Consensus",
|
||||
sections: {
|
||||
corePrinciple: "Silence is consent.",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "Post a deadline.",
|
||||
consensusLevel: 100,
|
||||
objectionsDeadlocks: "Any member can block.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
const blocks = section?.entries[0]?.blocks ?? [];
|
||||
expect(blocks).toContainEqual({
|
||||
label: "Consensus Level",
|
||||
body: "100%",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate consensus when a wizard proportion block exists", () => {
|
||||
const section = sectionFromDecision(
|
||||
[
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000002",
|
||||
label: "Custom vote",
|
||||
supportText: "We vote in person.",
|
||||
sections: {
|
||||
...emptyDecisionSections,
|
||||
consensusLevel: 75,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
"00000000-0000-4000-8000-000000000002": [
|
||||
{
|
||||
kind: "proportion",
|
||||
id: "q",
|
||||
blockTitle: "Quorum",
|
||||
defaultPercent: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const blocks = section?.entries[0]?.blocks ?? [];
|
||||
expect(blocks).toEqual([{ label: "Quorum", body: "60%" }]);
|
||||
expect(section?.entries[0]?.body).toBe("We vote in person.");
|
||||
});
|
||||
});
|
||||
@@ -25,5 +25,12 @@ export function renderWithProviders(
|
||||
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
|
||||
export * from "@testing-library/react";
|
||||
|
||||
Reference in New Issue
Block a user