Fix magic-link verify URLs in email and related create-flow QA #70
@@ -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,
|
||||||
|
|||||||
+3
@@ -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,6 +80,7 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
{value.consensusLevel !== undefined ? (
|
||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
label={t.sectionHeadings.consensusLevel}
|
label={t.sectionHeadings.consensusLevel}
|
||||||
helpIcon={false}
|
helpIcon={false}
|
||||||
@@ -93,6 +94,7 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
incrementAriaLabel="Increase consensus level"
|
incrementAriaLabel="Increase consensus level"
|
||||||
disabled={readOnly}
|
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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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._
|
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._
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
publishedMethodDisplayLabel,
|
publishedMethodDisplayLabel,
|
||||||
} from "./finalReviewChipPresets";
|
} from "./finalReviewChipPresets";
|
||||||
import { isDocumentEntry } from "./documentEntryGuards";
|
import { isDocumentEntry } from "./documentEntryGuards";
|
||||||
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
|
import {
|
||||||
|
replaceMethodSectionsWithMethodSelections,
|
||||||
|
withoutUnpublishedDecisionConsensus,
|
||||||
|
} from "./ruleSectionsFromMethodSelections";
|
||||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||||
|
|
||||||
export { isDocumentEntry } from "./documentEntryGuards";
|
export { isDocumentEntry } from "./documentEntryGuards";
|
||||||
@@ -78,21 +81,25 @@ export type PublishedMethodSelections = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: CommunicationMethodDetailEntry;
|
sections: CommunicationMethodDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
membership?: Array<{
|
membership?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: MembershipMethodDetailEntry;
|
sections: MembershipMethodDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
decisionApproaches?: Array<{
|
decisionApproaches?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: DecisionApproachDetailEntry;
|
sections: DecisionApproachDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
conflictManagement?: Array<{
|
conflictManagement?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
sections: ConflictManagementDetailEntry;
|
sections: ConflictManagementDetailEntry;
|
||||||
|
supportText?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,6 +254,14 @@ function pickMethodIds(
|
|||||||
return derived;
|
return derived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishedRowSupportText(
|
||||||
|
id: string,
|
||||||
|
meta: CreateFlowState["customMethodCardMetaById"],
|
||||||
|
): string | undefined {
|
||||||
|
const t = meta?.[id]?.supportText?.trim();
|
||||||
|
return t && t.length > 0 ? t : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||||
* overrides authored on the final-review screen. Preset defaults from the
|
* overrides authored on the final-review screen. Preset defaults from the
|
||||||
@@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.communication = commIds.map((id) => {
|
out.communication = commIds.map((id) => {
|
||||||
const preset = communicationPresetFor(id);
|
const preset = communicationPresetFor(id);
|
||||||
const override = state.communicationMethodDetailsById?.[id];
|
const override = state.communicationMethodDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.membership = memIds.map((id) => {
|
out.membership = memIds.map((id) => {
|
||||||
const preset = membershipPresetFor(id);
|
const preset = membershipPresetFor(id);
|
||||||
const override = state.membershipMethodDetailsById?.[id];
|
const override = state.membershipMethodDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.decisionApproaches = daIds.map((id) => {
|
out.decisionApproaches = daIds.map((id) => {
|
||||||
const preset = decisionApproachPresetFor(id);
|
const preset = decisionApproachPresetFor(id);
|
||||||
const override = state.decisionApproachDetailsById?.[id];
|
const override = state.decisionApproachDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
|
const merged = override ? { ...preset, ...override } : preset;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument(
|
|||||||
id,
|
id,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: withoutUnpublishedDecisionConsensus(
|
||||||
|
{ ...merged },
|
||||||
|
state.customMethodCardFieldBlocksById?.[id],
|
||||||
|
) as DecisionApproachDetailEntry,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -330,6 +364,10 @@ export function buildMethodSelectionsForDocument(
|
|||||||
out.conflictManagement = cmIds.map((id) => {
|
out.conflictManagement = cmIds.map((id) => {
|
||||||
const preset = conflictManagementPresetFor(id);
|
const preset = conflictManagementPresetFor(id);
|
||||||
const override = state.conflictManagementDetailsById?.[id];
|
const override = state.conflictManagementDetailsById?.[id];
|
||||||
|
const supportText = publishedRowSupportText(
|
||||||
|
id,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: publishedMethodDisplayLabel(
|
label: publishedMethodDisplayLabel(
|
||||||
@@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument(
|
|||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
),
|
),
|
||||||
sections: override ? { ...preset, ...override } : preset,
|
sections: override ? { ...preset, ...override } : preset,
|
||||||
|
...(supportText ? { supportText } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */
|
/** Default consensus level used when a **catalog** preset omits a value. */
|
||||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||||
|
|
||||||
export function decisionApproachPresetFor(
|
export function decisionApproachPresetFor(
|
||||||
@@ -114,19 +114,22 @@ export function decisionApproachPresetFor(
|
|||||||
): DecisionApproachDetailEntry {
|
): DecisionApproachDetailEntry {
|
||||||
const method = findMethod(decisionApproachesMessages, id);
|
const method = findMethod(decisionApproachesMessages, id);
|
||||||
const s = method?.sections ?? {};
|
const s = method?.sections ?? {};
|
||||||
return {
|
const entry: DecisionApproachDetailEntry = {
|
||||||
corePrinciple: asString(s.corePrinciple),
|
corePrinciple: asString(s.corePrinciple),
|
||||||
applicableScope: asStringArray(s.applicableScope),
|
applicableScope: asStringArray(s.applicableScope),
|
||||||
selectedApplicableScope: [],
|
selectedApplicableScope: [],
|
||||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||||
consensusLevel: asNumberClamped(
|
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||||
|
};
|
||||||
|
if (method) {
|
||||||
|
entry.consensusLevel = asNumberClamped(
|
||||||
s.consensusLevel,
|
s.consensusLevel,
|
||||||
0,
|
0,
|
||||||
100,
|
100,
|
||||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||||
),
|
);
|
||||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
}
|
||||||
};
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conflictManagementPresetFor(
|
export function conflictManagementPresetFor(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks(
|
|||||||
prefill.headings.stepByStepInstructions,
|
prefill.headings.stepByStepInstructions,
|
||||||
prefill.draft.stepByStepInstructions,
|
prefill.draft.stepByStepInstructions,
|
||||||
),
|
),
|
||||||
{
|
);
|
||||||
|
if (
|
||||||
|
typeof prefill.draft.consensusLevel === "number" &&
|
||||||
|
facetPrefillHasContent(prefill)
|
||||||
|
) {
|
||||||
|
blocks.push({
|
||||||
kind: "proportion",
|
kind: "proportion",
|
||||||
id: "facet-consensusLevel",
|
id: "facet-consensusLevel",
|
||||||
blockTitle: prefill.headings.consensusLevel,
|
blockTitle: prefill.headings.consensusLevel,
|
||||||
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
defaultPercent: clampPercent(prefill.draft.consensusLevel),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
blocks.push(
|
||||||
textBlock(
|
textBlock(
|
||||||
"facet-objectionsDeadlocks",
|
"facet-objectionsDeadlocks",
|
||||||
prefill.headings.objectionsDeadlocks,
|
prefill.headings.objectionsDeadlocks,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections(
|
|||||||
| Array<{
|
| Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
supportText?: string;
|
||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
) => {
|
) => {
|
||||||
@@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections(
|
|||||||
if (methodLabelFor(groupKey, id).length > 0) continue;
|
if (methodLabelFor(groupKey, id).length > 0) continue;
|
||||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
meta[id] = { label, supportText: "" };
|
const supportText =
|
||||||
|
typeof row.supportText === "string" ? row.supportText : "";
|
||||||
|
meta[id] = { label, supportText };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
absorb("communication", ms.communication);
|
absorb("communication", ms.communication);
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
|||||||
export type CommunityRuleEntryFromChipOptions = {
|
export type CommunityRuleEntryFromChipOptions = {
|
||||||
consensusLevelKey?: string;
|
consensusLevelKey?: string;
|
||||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||||
|
/** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */
|
||||||
|
supportText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Canonical `categoryName` strings for method groups in published documents. */
|
/** Canonical `categoryName` strings for method groups in published documents. */
|
||||||
@@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip(
|
|||||||
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
||||||
: [];
|
: [];
|
||||||
const blocks = [...presetBlocks, ...wizardBlocks];
|
const blocks = [...presetBlocks, ...wizardBlocks];
|
||||||
if (blocks.length === 0) return null;
|
const description = nonEmptyTrimmed(options?.supportText);
|
||||||
return { title, body: "", blocks };
|
if (blocks.length === 0) {
|
||||||
|
if (!description) return null;
|
||||||
|
return { title, body: description };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
body: description ?? "",
|
||||||
|
blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionApproachHasPublishableFacetCopy(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
nonEmptyTrimmed(sections.corePrinciple) ||
|
||||||
|
nonEmptyTrimmed(sections.stepByStepInstructions) ||
|
||||||
|
nonEmptyTrimmed(sections.objectionsDeadlocks) ||
|
||||||
|
formatScopePayload(sections.applicableScope) ||
|
||||||
|
formatScopePayload(sections.selectedApplicableScope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog methods publish their consensus figure. User-authored custom cards
|
||||||
|
* often seed `75` with empty facet copy — skip that unless the author actually
|
||||||
|
* filled decision sections. Wizard field blocks (including proportion) are the
|
||||||
|
* source of truth when present.
|
||||||
|
*/
|
||||||
|
function shouldPublishDecisionConsensusLevel(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
typeof sections.consensusLevel !== "number" ||
|
||||||
|
Number.isNaN(sections.consensusLevel)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (customFieldBlocks && customFieldBlocks.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return decisionApproachHasPublishableFacetCopy(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop seeded / wizard-superseded `consensusLevel` before publish or hydrate. */
|
||||||
|
export function withoutUnpublishedDecisionConsensus(
|
||||||
|
sections: Record<string, unknown>,
|
||||||
|
customFieldBlocks?: CustomMethodCardFieldBlock[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (shouldPublishDecisionConsensusLevel(sections, customFieldBlocks)) {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
if (!("consensusLevel" in sections)) return sections;
|
||||||
|
const next = { ...sections };
|
||||||
|
delete next.consensusLevel;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sectionFromCommunication(
|
export function sectionFromCommunication(
|
||||||
@@ -209,6 +267,7 @@ export function sectionFromCommunication(
|
|||||||
const sec = m.sections as unknown as Record<string, unknown>;
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
|
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
|
||||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
});
|
});
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
@@ -227,6 +286,7 @@ export function sectionFromMembership(
|
|||||||
const sec = m.sections as unknown as Record<string, unknown>;
|
const sec = m.sections as unknown as Record<string, unknown>;
|
||||||
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
|
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
|
||||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||||
|
supportText: m.supportText,
|
||||||
});
|
});
|
||||||
if (e) entries.push(e);
|
if (e) entries.push(e);
|
||||||
}
|
}
|
||||||
@@ -254,10 +314,19 @@ export function sectionFromDecision(
|
|||||||
);
|
);
|
||||||
if (scope) merged.applicableScope = scope;
|
if (scope) merged.applicableScope = scope;
|
||||||
delete merged.selectedApplicableScope;
|
delete merged.selectedApplicableScope;
|
||||||
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
|
const e = communityRuleEntryFromMethodChip(
|
||||||
|
m.label,
|
||||||
|
withoutUnpublishedDecisionConsensus(
|
||||||
|
merged,
|
||||||
|
customFieldBlocksById?.[m.id],
|
||||||
|
),
|
||||||
|
DEC_LABELS,
|
||||||
|
{
|
||||||
consensusLevelKey: "consensusLevel",
|
consensusLevelKey: "consensusLevel",
|
||||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
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;
|
||||||
|
|||||||
+98
-67
@@ -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, "&")
|
||||||
): Promise<void> {
|
.replace(/</g, "<")
|
||||||
const url = getSmtpUrl();
|
.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") {
|
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}`,
|
|
||||||
);
|
);
|
||||||
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: `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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user