Final review edit modals created
This commit is contained in:
@@ -34,7 +34,10 @@ import {
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { deleteServerDraft } from "../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import { buildTemplateCustomizePrefill } from "../../../lib/create/applyTemplatePrefill";
|
||||
import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../../lib/create/applyTemplatePrefill";
|
||||
import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug";
|
||||
import messages from "../../../messages/en/index";
|
||||
import {
|
||||
@@ -298,6 +301,22 @@ function CreateFlowLayoutContent({
|
||||
// don't bleed into `document.coreValues` at publish time.
|
||||
resetCustomRuleSelections();
|
||||
|
||||
// Seed the core-values snapshot from the Values section so the
|
||||
// final-review chip modal can edit them (it keys edits by chip id).
|
||||
// The Values entries themselves are then dropped from `sections` to
|
||||
// avoid publishing `document.coreValues` and `document.sections.Values`
|
||||
// for the same data — matches the "Customize" path's data shape.
|
||||
const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc);
|
||||
const sectionsWithoutValues =
|
||||
Object.keys(coreValuesPrefill).length > 0
|
||||
? sections.filter((s) => {
|
||||
const name = (s as { categoryName?: unknown }).categoryName;
|
||||
if (typeof name !== "string") return true;
|
||||
const key = name.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
return key !== "values" && key !== "corevalues";
|
||||
})
|
||||
: sections;
|
||||
|
||||
const summaryRaw =
|
||||
typeof template.description === "string"
|
||||
? template.description.trim()
|
||||
@@ -305,7 +324,8 @@ function CreateFlowLayoutContent({
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
sections,
|
||||
...coreValuesPrefill,
|
||||
sections: sectionsWithoutValues,
|
||||
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editable mirror of {@link TemplateChipDetailModal} for the final-review
|
||||
* screen. Each chip on `/create/final-review` opens this modal — same field
|
||||
* set as the matching custom-rule add-method modals, but with a **Save**
|
||||
* button instead of **Add**:
|
||||
*
|
||||
* - Initial field values come from the matching `{group}DetailsById` state
|
||||
* override when present; otherwise from the preset defaults shipped in
|
||||
* `messages/en/create/customRule/*.json` (see {@link finalReviewChipPresets}).
|
||||
* - Save is disabled until the user edits any field (cheap structural
|
||||
* compare against the seeded snapshot). Saving writes the draft into
|
||||
* `CreateFlowState` via the caller's `onSave` handler and closes; the
|
||||
* state then rides along through the existing localStorage mirror,
|
||||
* signed-in server draft PUT (Save & Exit), and `buildPublishPayload`
|
||||
* (Finalize).
|
||||
* - Closing the modal without saving discards any edits — the parent never
|
||||
* hears about them.
|
||||
*
|
||||
* The actual field rendering lives in `components/methodEditFields/*` and
|
||||
* is shared with the custom-rule add-method modals so the two surfaces stay
|
||||
* in lockstep automatically.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import {
|
||||
CommunicationMethodEditFields,
|
||||
ConflictManagementEditFields,
|
||||
CoreValueEditFields,
|
||||
DecisionApproachEditFields,
|
||||
MembershipMethodEditFields,
|
||||
} from "./methodEditFields";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
coreValuePresetFor,
|
||||
decisionApproachPresetFor,
|
||||
membershipPresetFor,
|
||||
} from "../../../../lib/create/finalReviewChipPresets";
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
CoreValueDetailEntry,
|
||||
CreateFlowState,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../types";
|
||||
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
|
||||
|
||||
export type FinalReviewChipEditTarget = {
|
||||
/** Stable key for override lookup: preset id (methods) or chip id (core values). */
|
||||
overrideKey: string;
|
||||
/** Category group that decides which field set to render. */
|
||||
groupKey: TemplateFacetGroupKey;
|
||||
/** Display label shown at the top of the modal (localized chip label). */
|
||||
chipLabel: string;
|
||||
};
|
||||
|
||||
export type FinalReviewChipEditPatch =
|
||||
| { groupKey: "coreValues"; overrideKey: string; value: CoreValueDetailEntry }
|
||||
| {
|
||||
groupKey: "communication";
|
||||
overrideKey: string;
|
||||
value: CommunicationMethodDetailEntry;
|
||||
}
|
||||
| {
|
||||
groupKey: "membership";
|
||||
overrideKey: string;
|
||||
value: MembershipMethodDetailEntry;
|
||||
}
|
||||
| {
|
||||
groupKey: "decisionApproaches";
|
||||
overrideKey: string;
|
||||
value: DecisionApproachDetailEntry;
|
||||
}
|
||||
| {
|
||||
groupKey: "conflictManagement";
|
||||
overrideKey: string;
|
||||
value: ConflictManagementDetailEntry;
|
||||
};
|
||||
|
||||
export interface FinalReviewChipEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Chip being edited. Passed `null` while the modal is closing so the
|
||||
* component can cleanly reset its internal draft state.
|
||||
*/
|
||||
target: FinalReviewChipEditTarget | null;
|
||||
/** Current flow state — used to seed the modal from saved overrides. */
|
||||
state: CreateFlowState;
|
||||
/** Called with the typed patch when the user clicks Save. */
|
||||
onSave: (_patch: FinalReviewChipEditPatch) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of every group's draft + value. Storing both the
|
||||
* `groupKey` and the `value` together keeps render-time switches exhaustive
|
||||
* and prevents the four method group states from drifting apart (which is
|
||||
* the bug that motivated extracting `methodEditFields/*` in the first place).
|
||||
*/
|
||||
type Draft =
|
||||
| { groupKey: "coreValues"; value: CoreValueDetailEntry }
|
||||
| { groupKey: "communication"; value: CommunicationMethodDetailEntry }
|
||||
| { groupKey: "membership"; value: MembershipMethodDetailEntry }
|
||||
| { groupKey: "decisionApproaches"; value: DecisionApproachDetailEntry }
|
||||
| { groupKey: "conflictManagement"; value: ConflictManagementDetailEntry };
|
||||
|
||||
export function FinalReviewChipEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
target,
|
||||
state,
|
||||
onSave,
|
||||
}: FinalReviewChipEditModalProps) {
|
||||
const m = useMessages();
|
||||
const tCv = m.create.customRule.coreValues;
|
||||
const tComm = m.create.customRule.communication;
|
||||
const tMem = m.create.customRule.membership;
|
||||
const tDa = m.create.customRule.decisionApproaches;
|
||||
const tCm = m.create.customRule.conflictManagement;
|
||||
const tModal = useTranslation(
|
||||
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||
);
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
/**
|
||||
* JSON-stringified seed used for the cheap dirty check. Re-captured on
|
||||
* every (re)open so reopening a chip after a save shows Save-disabled
|
||||
* again until the user makes a fresh edit.
|
||||
*/
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
const seededTargetRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !target) return;
|
||||
const targetKey = `${target.groupKey}:${target.overrideKey}`;
|
||||
if (seededTargetRef.current === targetKey) return;
|
||||
|
||||
const seed = seedDraftForTarget(target, state);
|
||||
setDraft(seed);
|
||||
initialSnapshotRef.current = JSON.stringify(seed.value);
|
||||
seededTargetRef.current = targetKey;
|
||||
}, [isOpen, target, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) seededTargetRef.current = null;
|
||||
}, [isOpen]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!draft) return false;
|
||||
return JSON.stringify(draft.value) !== initialSnapshotRef.current;
|
||||
}, [draft]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!target || !draft || !isDirty) return;
|
||||
onSave({
|
||||
groupKey: draft.groupKey,
|
||||
overrideKey: target.overrideKey,
|
||||
value: draft.value,
|
||||
} as FinalReviewChipEditPatch);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (!target) return "";
|
||||
return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm });
|
||||
}, [target, tCv, tComm, tMem, tDa, tCm]);
|
||||
|
||||
return (
|
||||
<Create
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
backdropVariant="loginYellow"
|
||||
headerContent={
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={target?.chipLabel ?? ""}
|
||||
description={subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
nextButtonText={tModal("saveButton")}
|
||||
nextButtonDisabled={!isDirty}
|
||||
onNext={handleSave}
|
||||
ariaLabel={target?.chipLabel || "Edit chip details"}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||
{draft?.groupKey === "coreValues" && (
|
||||
<CoreValueEditFields
|
||||
value={draft.value}
|
||||
onChange={(value) => setDraft({ groupKey: "coreValues", value })}
|
||||
/>
|
||||
)}
|
||||
{draft?.groupKey === "communication" && (
|
||||
<CommunicationMethodEditFields
|
||||
value={draft.value}
|
||||
onChange={(value) =>
|
||||
setDraft({ groupKey: "communication", value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{draft?.groupKey === "membership" && (
|
||||
<MembershipMethodEditFields
|
||||
value={draft.value}
|
||||
onChange={(value) => setDraft({ groupKey: "membership", value })}
|
||||
/>
|
||||
)}
|
||||
{draft?.groupKey === "decisionApproaches" && (
|
||||
<DecisionApproachEditFields
|
||||
value={draft.value}
|
||||
onChange={(value) =>
|
||||
setDraft({ groupKey: "decisionApproaches", value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{draft?.groupKey === "conflictManagement" && (
|
||||
<ConflictManagementEditFields
|
||||
value={draft.value}
|
||||
onChange={(value) =>
|
||||
setDraft({ groupKey: "conflictManagement", value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Create>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- helpers ------------------------------------------------------
|
||||
|
||||
function seedDraftForTarget(
|
||||
target: FinalReviewChipEditTarget,
|
||||
state: CreateFlowState,
|
||||
): Draft {
|
||||
switch (target.groupKey) {
|
||||
case "coreValues": {
|
||||
const saved = state.coreValueDetailsByChipId?.[target.overrideKey];
|
||||
const preset = coreValuePresetFor(target.overrideKey);
|
||||
return {
|
||||
groupKey: "coreValues",
|
||||
value: {
|
||||
meaning: saved?.meaning ?? preset.meaning,
|
||||
signals: saved?.signals ?? preset.signals,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "communication": {
|
||||
const saved =
|
||||
state.communicationMethodDetailsById?.[target.overrideKey] ??
|
||||
communicationPresetFor(target.overrideKey);
|
||||
return { groupKey: "communication", value: { ...saved } };
|
||||
}
|
||||
case "membership": {
|
||||
const saved =
|
||||
state.membershipMethodDetailsById?.[target.overrideKey] ??
|
||||
membershipPresetFor(target.overrideKey);
|
||||
return { groupKey: "membership", value: { ...saved } };
|
||||
}
|
||||
case "decisionApproaches": {
|
||||
const saved =
|
||||
state.decisionApproachDetailsById?.[target.overrideKey] ??
|
||||
decisionApproachPresetFor(target.overrideKey);
|
||||
return {
|
||||
groupKey: "decisionApproaches",
|
||||
value: {
|
||||
...saved,
|
||||
applicableScope: [...saved.applicableScope],
|
||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||
},
|
||||
};
|
||||
}
|
||||
case "conflictManagement": {
|
||||
const saved =
|
||||
state.conflictManagementDetailsById?.[target.overrideKey] ??
|
||||
conflictManagementPresetFor(target.overrideKey);
|
||||
return {
|
||||
groupKey: "conflictManagement",
|
||||
value: {
|
||||
...saved,
|
||||
applicableScope: [...saved.applicableScope],
|
||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SubtitleMessages = {
|
||||
tCv: ReturnType<typeof useMessages>["create"]["customRule"]["coreValues"];
|
||||
tComm: ReturnType<typeof useMessages>["create"]["customRule"]["communication"];
|
||||
tMem: ReturnType<typeof useMessages>["create"]["customRule"]["membership"];
|
||||
tDa: ReturnType<
|
||||
typeof useMessages
|
||||
>["create"]["customRule"]["decisionApproaches"];
|
||||
tCm: ReturnType<
|
||||
typeof useMessages
|
||||
>["create"]["customRule"]["conflictManagement"];
|
||||
};
|
||||
|
||||
function subtitleForTarget(
|
||||
target: FinalReviewChipEditTarget,
|
||||
msgs: SubtitleMessages,
|
||||
): string {
|
||||
switch (target.groupKey) {
|
||||
case "coreValues":
|
||||
return msgs.tCv.detailModal.subtitle;
|
||||
case "communication":
|
||||
return findMethodSupportText(msgs.tComm.methods, target.overrideKey);
|
||||
case "membership":
|
||||
return findMethodSupportText(msgs.tMem.methods, target.overrideKey);
|
||||
case "decisionApproaches":
|
||||
return findMethodSupportText(msgs.tDa.methods, target.overrideKey);
|
||||
case "conflictManagement":
|
||||
return findMethodSupportText(msgs.tCm.methods, target.overrideKey);
|
||||
}
|
||||
}
|
||||
|
||||
function findMethodSupportText(
|
||||
methods: readonly { id: string; supportText: string }[],
|
||||
id: string,
|
||||
): string {
|
||||
for (const method of methods) {
|
||||
if (method.id === id) return method.supportText;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled section editor for a communication-method chip. Used by both
|
||||
* the custom-rule `communication-methods` add-method modal and the
|
||||
* `final-review` chip edit modal — caller owns draft state and decides when
|
||||
* to persist or discard.
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
|
||||
export interface CommunicationMethodEditFieldsProps {
|
||||
value: CommunicationMethodDetailEntry;
|
||||
onChange: (_next: CommunicationMethodDetailEntry) => void;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
"codeOfConduct",
|
||||
];
|
||||
|
||||
function CommunicationMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
}: CommunicationMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.communication;
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof CommunicationMethodDetailEntry>(
|
||||
key: K,
|
||||
next: CommunicationMethodDetailEntry[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: next });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{FIELDS.map((field) => (
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
label={t.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommunicationMethodEditFieldsComponent.displayName =
|
||||
"CommunicationMethodEditFields";
|
||||
|
||||
export default memo(CommunicationMethodEditFieldsComponent);
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled section editor for a conflict-management chip. Used by both the
|
||||
* custom-rule `conflict-management` add-method modal and the `final-review`
|
||||
* chip edit modal. Caller owns draft state and persistence.
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import ApplicableScopeField from "../ApplicableScopeField";
|
||||
import type { ConflictManagementDetailEntry } from "../../types";
|
||||
|
||||
export interface ConflictManagementEditFieldsProps {
|
||||
value: ConflictManagementDetailEntry;
|
||||
onChange: (_next: ConflictManagementDetailEntry) => void;
|
||||
}
|
||||
|
||||
function ConflictManagementEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
}: ConflictManagementEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.conflictManagement;
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof ConflictManagementDetailEntry>(
|
||||
key: K,
|
||||
next: ConflictManagementDetailEntry[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: next });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={value.applicableScope}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
value.selectedApplicableScope.includes(scope)
|
||||
? value.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...value.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...value.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.processProtocol}
|
||||
value={value.processProtocol}
|
||||
onChange={(v) => patch("processProtocol", v)}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.restorationFallbacks}
|
||||
value={value.restorationFallbacks}
|
||||
onChange={(v) => patch("restorationFallbacks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConflictManagementEditFieldsComponent.displayName =
|
||||
"ConflictManagementEditFields";
|
||||
|
||||
export default memo(ConflictManagementEditFieldsComponent);
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled meaning/signals field set for a core-value chip. Rendered both
|
||||
* by `core-values` (custom-rule selection step) and `final-review` (chip
|
||||
* edit modal). Holds no state — the parent owns the draft and decides when
|
||||
* to persist (`updateState`) or discard.
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import type { CoreValueDetailEntry } from "../../types";
|
||||
|
||||
export interface CoreValueEditFieldsProps {
|
||||
value: CoreValueDetailEntry;
|
||||
onChange: (_next: CoreValueDetailEntry) => void;
|
||||
}
|
||||
|
||||
function CoreValueEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
}: CoreValueEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.coreValues.detailModal;
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof CoreValueDetailEntry>(
|
||||
key: K,
|
||||
next: CoreValueDetailEntry[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: next });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||
<ModalTextAreaField
|
||||
label={t.meaningLabel}
|
||||
value={value.meaning}
|
||||
onChange={(v) => patch("meaning", v)}
|
||||
rows={4}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.signalsLabel}
|
||||
value={value.signals}
|
||||
onChange={(v) => patch("signals", v)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
|
||||
|
||||
export default memo(CoreValueEditFieldsComponent);
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled section editor for a decision-approach chip. Used by both the
|
||||
* custom-rule `decision-approaches` add-method modal and the `final-review`
|
||||
* chip edit modal. Caller owns draft state — Confirm/Save persistence and
|
||||
* `markCreateFlowInteraction` live in the parent.
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import ApplicableScopeField from "../ApplicableScopeField";
|
||||
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||
import type { DecisionApproachDetailEntry } from "../../types";
|
||||
|
||||
export interface DecisionApproachEditFieldsProps {
|
||||
value: DecisionApproachDetailEntry;
|
||||
onChange: (_next: DecisionApproachDetailEntry) => void;
|
||||
}
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
const CONSENSUS_LEVEL_MAX = 100;
|
||||
const CONSENSUS_LEVEL_STEP = 5;
|
||||
|
||||
function DecisionApproachEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
}: DecisionApproachEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.decisionApproaches;
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof DecisionApproachDetailEntry>(
|
||||
key: K,
|
||||
next: DecisionApproachDetailEntry[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: next });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={value.applicableScope}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
value.selectedApplicableScope.includes(scope)
|
||||
? value.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...value.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...value.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.stepByStepInstructions}
|
||||
value={value.stepByStepInstructions}
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
value={value.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => patch("consensusLevel", next)}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.objectionsDeadlocks}
|
||||
value={value.objectionsDeadlocks}
|
||||
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DecisionApproachEditFieldsComponent.displayName =
|
||||
"DecisionApproachEditFields";
|
||||
|
||||
export default memo(DecisionApproachEditFieldsComponent);
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Controlled section editor for a membership-method chip. Used by both the
|
||||
* custom-rule `membership-methods` add-method modal and the `final-review`
|
||||
* chip edit modal — caller owns draft state and decides when to persist or
|
||||
* discard.
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import ModalTextAreaField from "../ModalTextAreaField";
|
||||
import type { MembershipMethodDetailEntry } from "../../types";
|
||||
|
||||
export interface MembershipMethodEditFieldsProps {
|
||||
value: MembershipMethodDetailEntry;
|
||||
onChange: (_next: MembershipMethodDetailEntry) => void;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||
"eligibility",
|
||||
"joiningProcess",
|
||||
"expectations",
|
||||
];
|
||||
|
||||
function MembershipMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
}: MembershipMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.membership;
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof MembershipMethodDetailEntry>(
|
||||
key: K,
|
||||
next: MembershipMethodDetailEntry[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: next });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{FIELDS.map((field) => (
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
label={t.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MembershipMethodEditFieldsComponent.displayName =
|
||||
"MembershipMethodEditFields";
|
||||
|
||||
export default memo(MembershipMethodEditFieldsComponent);
|
||||
@@ -0,0 +1,14 @@
|
||||
export { default as CoreValueEditFields } from "./CoreValueEditFields";
|
||||
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
|
||||
|
||||
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
|
||||
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
|
||||
|
||||
export { default as MembershipMethodEditFields } from "./MembershipMethodEditFields";
|
||||
export type { MembershipMethodEditFieldsProps } from "./MembershipMethodEditFields";
|
||||
|
||||
export { default as DecisionApproachEditFields } from "./DecisionApproachEditFields";
|
||||
export type { DecisionApproachEditFieldsProps } from "./DecisionApproachEditFields";
|
||||
|
||||
export { default as ConflictManagementEditFields } from "./ConflictManagementEditFields";
|
||||
export type { ConflictManagementEditFieldsProps } from "./ConflictManagementEditFields";
|
||||
@@ -7,6 +7,11 @@
|
||||
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
||||
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
||||
*
|
||||
* Card click opens the Figma "Add Platform" create modal (node `20246-15829`) with three
|
||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The same field set is
|
||||
* reused on `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both
|
||||
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
@@ -27,61 +32,9 @@ import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
"codeOfConduct",
|
||||
] as const;
|
||||
type SectionField = (typeof SECTION_FIELDS)[number];
|
||||
|
||||
function AddPlatformModalContent({
|
||||
platformCardId,
|
||||
}: {
|
||||
platformCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const comm = m.create.customRule.communication;
|
||||
const method = comm.methods.find((entry) => entry.id === platformCardId);
|
||||
const sections = method?.sections;
|
||||
const defaults: Record<SectionField, string> = {
|
||||
corePrinciple: sections?.corePrinciple ?? "",
|
||||
logisticsAdmin: sections?.logisticsAdmin ?? "",
|
||||
codeOfConduct: sections?.codeOfConduct ?? "",
|
||||
};
|
||||
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionField, string>
|
||||
>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
logisticsAdmin: defaults.logisticsAdmin,
|
||||
codeOfConduct: defaults.codeOfConduct,
|
||||
}));
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionField, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
label={comm.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
|
||||
export function CommunicationMethodsScreen() {
|
||||
const m = useMessages();
|
||||
@@ -91,16 +44,11 @@ export function CommunicationMethodsScreen() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<CommunicationMethodDetailEntry | null>(null);
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedCommunicationMethodIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("communication");
|
||||
const rankedMethods = useMemo(
|
||||
@@ -148,55 +96,81 @@ export function CommunicationMethodsScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? comm.confirmModal.title,
|
||||
description: method?.supportText ?? comm.confirmModal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? comm.confirmModal.title,
|
||||
description: method?.supportText ?? comm.confirmModal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
const seedDraft = useCallback(
|
||||
(id: string): CommunicationMethodDetailEntry => {
|
||||
const saved = state.communicationMethodDetailsById?.[id];
|
||||
if (saved) {
|
||||
return { ...saved };
|
||||
}
|
||||
return communicationPresetFor(id);
|
||||
},
|
||||
[state.communicationMethodDetailsById],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction, seedDraft],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(next: CommunicationMethodDetailEntry) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
selectedCommunicationMethodIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.communicationMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -238,15 +212,14 @@ export function CommunicationMethodsScreen() {
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
showBackButton={false}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddPlatformModalContent
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<CommunicationMethodEditFields
|
||||
key={pendingCardId}
|
||||
platformCardId={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
|
||||
*
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
|
||||
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
|
||||
* & Fallbacks. Section defaults are sourced from
|
||||
* `messages/en/create/customRule/conflictManagement.json` and will be replaced with DB-driven
|
||||
* content; labels are hard-coded per the Figma design.
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
|
||||
* with four controls rendered by {@link ConflictManagementEditFields}: Core
|
||||
* Principle, Applicable Scope (capsules), Process Protocol, and Restoration
|
||||
* & Fallbacks. The same field set is reused on `/create/final-review` — see
|
||||
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
|
||||
* any user edits as a `conflictManagementDetailsById[id]` override.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
@@ -29,91 +30,9 @@ import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
import ApplicableScopeField from "../../components/ApplicableScopeField";
|
||||
|
||||
type ConflictModalSections = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
processProtocol: string;
|
||||
restorationFallbacks: string;
|
||||
};
|
||||
|
||||
function AddConflictApproachModalContent({
|
||||
approachCardId,
|
||||
}: {
|
||||
approachCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const cm = m.create.customRule.conflictManagement;
|
||||
const method = cm.methods.find((entry) => entry.id === approachCardId);
|
||||
const modalSections = method?.sections;
|
||||
const defaults: ConflictModalSections = {
|
||||
corePrinciple: modalSections?.corePrinciple ?? "",
|
||||
applicableScope: modalSections?.applicableScope ?? [],
|
||||
selectedApplicableScope: [],
|
||||
processProtocol: modalSections?.processProtocol ?? "",
|
||||
restorationFallbacks: modalSections?.restorationFallbacks ?? "",
|
||||
};
|
||||
|
||||
const [sections, setSections] = useState<ConflictModalSections>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
applicableScope: [...defaults.applicableScope],
|
||||
selectedApplicableScope: [...defaults.selectedApplicableScope],
|
||||
processProtocol: defaults.processProtocol,
|
||||
restorationFallbacks: defaults.restorationFallbacks,
|
||||
}));
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof ConflictModalSections>(
|
||||
key: K,
|
||||
value: ConflictModalSections[K],
|
||||
) => {
|
||||
markCreateFlowInteraction();
|
||||
setSections((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.corePrinciple}
|
||||
value={sections.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={cm.sectionHeadings.applicableScope}
|
||||
addLabel={cm.scopeAddButtonLabel}
|
||||
scopes={sections.applicableScope}
|
||||
selectedScopes={sections.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
sections.selectedApplicableScope.includes(scope)
|
||||
? sections.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...sections.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...sections.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.processProtocol}
|
||||
value={sections.processProtocol}
|
||||
onChange={(v) => patch("processProtocol", v)}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.restorationFallbacks}
|
||||
value={sections.restorationFallbacks}
|
||||
onChange={(v) => patch("restorationFallbacks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { ConflictManagementDetailEntry } from "../../types";
|
||||
|
||||
export function ConflictManagementScreen() {
|
||||
const m = useMessages();
|
||||
@@ -123,16 +42,11 @@ export function ConflictManagementScreen() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<ConflictManagementDetailEntry | null>(null);
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedConflictManagementIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("conflictManagement");
|
||||
const rankedMethods = useMemo(
|
||||
@@ -180,55 +94,85 @@ export function ConflictManagementScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? cm.confirmModal.title,
|
||||
description: method?.supportText ?? cm.confirmModal.description,
|
||||
nextButtonText: cm.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? cm.confirmModal.title,
|
||||
description: method?.supportText ?? cm.confirmModal.description,
|
||||
nextButtonText: cm.addApproach.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
const seedDraft = useCallback(
|
||||
(id: string): ConflictManagementDetailEntry => {
|
||||
const saved = state.conflictManagementDetailsById?.[id];
|
||||
if (saved) {
|
||||
return {
|
||||
...saved,
|
||||
applicableScope: [...saved.applicableScope],
|
||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||
};
|
||||
}
|
||||
return conflictManagementPresetFor(id);
|
||||
},
|
||||
[state.conflictManagementDetailsById],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction, seedDraft],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(next: ConflictManagementDetailEntry) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
selectedConflictManagementIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.conflictManagementDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -270,15 +214,14 @@ export function ConflictManagementScreen() {
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
showBackButton={false}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddConflictApproachModalContent
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<ConflictManagementEditFields
|
||||
key={pendingCardId}
|
||||
approachCardId={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
|
||||
*
|
||||
* Card click opens the Figma create modal (node `20858-13948`) with three
|
||||
* editable sections — Eligibility & Philosophy, Joining Process, and
|
||||
* Expectations & Removal. Section defaults come from
|
||||
* `messages/en/create/customRule/membership.json` and will be replaced with DB-driven
|
||||
* content.
|
||||
* editable sections rendered by {@link MembershipMethodEditFields}. The same
|
||||
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`.
|
||||
* Confirm persists both the chip selection and any user edits as a
|
||||
* `membershipMethodDetailsById[id]` override; section defaults come from
|
||||
* `messages/en/create/customRule/membership.json` and will be replaced with
|
||||
* DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
@@ -29,61 +31,9 @@ import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
"eligibility",
|
||||
"joiningProcess",
|
||||
"expectations",
|
||||
] as const;
|
||||
type SectionField = (typeof SECTION_FIELDS)[number];
|
||||
|
||||
function AddMembershipModalContent({
|
||||
membershipCardId,
|
||||
}: {
|
||||
membershipCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const mem = m.create.customRule.membership;
|
||||
const method = mem.methods.find((entry) => entry.id === membershipCardId);
|
||||
const sections = method?.sections;
|
||||
const defaults: Record<SectionField, string> = {
|
||||
eligibility: sections?.eligibility ?? "",
|
||||
joiningProcess: sections?.joiningProcess ?? "",
|
||||
expectations: sections?.expectations ?? "",
|
||||
};
|
||||
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionField, string>
|
||||
>(() => ({
|
||||
eligibility: defaults.eligibility,
|
||||
joiningProcess: defaults.joiningProcess,
|
||||
expectations: defaults.expectations,
|
||||
}));
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionField, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
label={mem.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { MembershipMethodDetailEntry } from "../../types";
|
||||
|
||||
export function MembershipMethodsScreen() {
|
||||
const m = useMessages();
|
||||
@@ -93,16 +43,11 @@ export function MembershipMethodsScreen() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<MembershipMethodDetailEntry | null>(null);
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedMembershipMethodIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("membership");
|
||||
const rankedMethods = useMemo(
|
||||
@@ -150,55 +95,81 @@ export function MembershipMethodsScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? mem.confirmModal.title,
|
||||
description: method?.supportText ?? mem.confirmModal.description,
|
||||
nextButtonText: mem.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? mem.confirmModal.title,
|
||||
description: method?.supportText ?? mem.confirmModal.description,
|
||||
nextButtonText: mem.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
const seedDraft = useCallback(
|
||||
(id: string): MembershipMethodDetailEntry => {
|
||||
const saved = state.membershipMethodDetailsById?.[id];
|
||||
if (saved) {
|
||||
return { ...saved };
|
||||
}
|
||||
return membershipPresetFor(id);
|
||||
},
|
||||
[state.membershipMethodDetailsById],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction, seedDraft],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(next: MembershipMethodDetailEntry) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
selectedMembershipMethodIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -240,15 +211,14 @@ export function MembershipMethodsScreen() {
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
showBackButton={false}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddMembershipModalContent
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<MembershipMethodEditFields
|
||||
key={pendingCardId}
|
||||
membershipCardId={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import RuleCard from "../../../../components/cards/RuleCard";
|
||||
import type { Category } from "../../../../components/cards/RuleCard/RuleCard.types";
|
||||
import { TemplateChipDetailModal } from "../../../../components/cards/TemplateReviewCard/TemplateChipDetailModal";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
@@ -11,22 +12,15 @@ import {
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../../components/CreateFlowLockupCardStepShell";
|
||||
import {
|
||||
buildFinalReviewCategoriesFromState,
|
||||
type FinalReviewCategoryRow,
|
||||
buildFinalReviewCategoryRowsDetailed,
|
||||
type FinalReviewCategoryRowDetailed,
|
||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||
|
||||
function buildFinalReviewCategories(
|
||||
rows: readonly FinalReviewCategoryRow[],
|
||||
): Category[] {
|
||||
return rows.map((cat) => ({
|
||||
name: cat.name,
|
||||
chipOptions: cat.chips.map((label, idx) => ({
|
||||
id: `${cat.name}-${idx}`,
|
||||
label,
|
||||
state: "unselected" as const,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
FinalReviewChipEditModal,
|
||||
type FinalReviewChipEditPatch,
|
||||
type FinalReviewChipEditTarget,
|
||||
} from "../../components/FinalReviewChipEditModal";
|
||||
|
||||
/**
|
||||
* `finalReview.json.categories` ships a demo ordering + localized names
|
||||
@@ -36,7 +30,7 @@ function buildFinalReviewCategories(
|
||||
* plain-custom flows, and fall back to the demo chips when state resolves
|
||||
* to nothing selected.
|
||||
*/
|
||||
function readFallbackCategoryNames(
|
||||
function readFallbackCategoryRows(
|
||||
categories: readonly { name: string; chips: readonly string[] }[],
|
||||
): {
|
||||
names: {
|
||||
@@ -46,7 +40,7 @@ function readFallbackCategoryNames(
|
||||
decisions: string;
|
||||
conflict: string;
|
||||
};
|
||||
rows: FinalReviewCategoryRow[];
|
||||
rows: FinalReviewCategoryRowDetailed[];
|
||||
} {
|
||||
const get = (i: number): string =>
|
||||
typeof categories[i]?.name === "string" ? categories[i].name : "";
|
||||
@@ -58,28 +52,158 @@ function readFallbackCategoryNames(
|
||||
decisions: get(3),
|
||||
conflict: get(4),
|
||||
},
|
||||
rows: categories.map((c) => ({ name: c.name, chips: [...c.chips] })),
|
||||
rows: categories.map((c) => ({
|
||||
name: c.name,
|
||||
groupKey: null,
|
||||
entries: [...c.chips].map((label) => ({
|
||||
label,
|
||||
groupKey: null,
|
||||
overrideKey: null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function FinalReviewScreen() {
|
||||
const { state } = useCreateFlow();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||
const m = useMessages();
|
||||
|
||||
const finalReviewCategories = useMemo(() => {
|
||||
const { names, rows: fallbackRows } = readFallbackCategoryNames(
|
||||
/**
|
||||
* Two modals coexist on this screen:
|
||||
*
|
||||
* - {@link FinalReviewChipEditModal} — editable Save-button version used
|
||||
* whenever the chip resolves to a stable `overrideKey` (core-value
|
||||
* chip id, or a method preset id). Writes through to
|
||||
* `{group}DetailsById` state fields on Save; close-without-save is a
|
||||
* no-op so any typed edits are discarded.
|
||||
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
||||
* can't map to an override key (e.g. template body entries on the
|
||||
* "Use without changes" path where no preset matches the title).
|
||||
*
|
||||
* `activeEditTarget` drives the editable modal; `activeReadOnlyDetail`
|
||||
* drives the read-only modal; only one is ever non-null at a time.
|
||||
*/
|
||||
const [activeEditTarget, setActiveEditTarget] =
|
||||
useState<FinalReviewChipEditTarget | null>(null);
|
||||
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
|
||||
useState<TemplateChipDetail | null>(null);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(patch: FinalReviewChipEditPatch) => {
|
||||
markCreateFlowInteraction();
|
||||
switch (patch.groupKey) {
|
||||
case "coreValues": {
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "communication": {
|
||||
updateState({
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "membership": {
|
||||
updateState({
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "decisionApproaches": {
|
||||
updateState({
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "conflictManagement": {
|
||||
updateState({
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[markCreateFlowInteraction, updateState, state],
|
||||
);
|
||||
|
||||
const { categories: finalReviewCategories, chipLookup } = useMemo(() => {
|
||||
const { names, rows: fallbackRows } = readFallbackCategoryRows(
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
);
|
||||
const derived = buildFinalReviewCategoriesFromState(state, names);
|
||||
// When a user lands on final review with nothing actually selected (e.g.
|
||||
// direct-nav during dev), keep the shipped demo chips rather than render
|
||||
// an empty card — matches prior behavior for that edge case.
|
||||
return buildFinalReviewCategories(
|
||||
derived.length > 0 ? derived : fallbackRows,
|
||||
);
|
||||
}, [m.create.reviewAndComplete.finalReview.categories, state]);
|
||||
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
||||
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
||||
derived.length > 0 ? derived : fallbackRows;
|
||||
|
||||
const lookup = new Map<
|
||||
string,
|
||||
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
|
||||
>();
|
||||
|
||||
const cats: Category[] = rowsToRender.map((row) => {
|
||||
const chipOptions = row.entries.map((entry, idx) => {
|
||||
const chipId = `${row.name}-${idx}`;
|
||||
const readOnly: TemplateChipDetail = {
|
||||
chipId,
|
||||
chipLabel: entry.label,
|
||||
categoryName: row.name,
|
||||
groupKey: entry.groupKey,
|
||||
body: "",
|
||||
};
|
||||
const target: FinalReviewChipEditTarget | null =
|
||||
entry.groupKey && entry.overrideKey
|
||||
? {
|
||||
overrideKey: entry.overrideKey,
|
||||
groupKey: entry.groupKey,
|
||||
chipLabel: entry.label,
|
||||
}
|
||||
: null;
|
||||
lookup.set(chipId, { target, readOnly });
|
||||
return {
|
||||
id: chipId,
|
||||
label: entry.label,
|
||||
state: "unselected" as const,
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: row.name,
|
||||
chipOptions,
|
||||
onChipClick: (_categoryName: string, chipId: string) => {
|
||||
const hit = lookup.get(chipId);
|
||||
if (!hit) return;
|
||||
markCreateFlowInteraction();
|
||||
if (hit.target) {
|
||||
setActiveEditTarget(hit.target);
|
||||
} else {
|
||||
setActiveReadOnlyDetail(hit.readOnly);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
return { categories: cats, chipLookup: lookup };
|
||||
}, [
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
state,
|
||||
markCreateFlowInteraction,
|
||||
]);
|
||||
void chipLookup;
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
@@ -109,6 +233,18 @@ export function FinalReviewScreen() {
|
||||
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<FinalReviewChipEditModal
|
||||
isOpen={activeEditTarget !== null}
|
||||
onClose={() => setActiveEditTarget(null)}
|
||||
target={activeEditTarget}
|
||||
state={state}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<TemplateChipDetailModal
|
||||
isOpen={activeReadOnlyDetail !== null}
|
||||
onClose={() => setActiveReadOnlyDetail(null)}
|
||||
detail={activeReadOnlyDetail}
|
||||
/>
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
|
||||
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
|
||||
*
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
|
||||
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
|
||||
* Deadlocks. Section defaults are sourced from `messages/en/create/customRule/decisionApproaches.json` (read
|
||||
* via `m.create.customRule.decisionApproaches`) and will be replaced with DB-driven content; labels are
|
||||
* hard-coded per the Figma design.
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls
|
||||
* rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step
|
||||
* Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on
|
||||
* `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both the chip
|
||||
* selection and any user edits as a `decisionApproachDetailsById[id]` override; section
|
||||
* defaults come from `messages/en/create/customRule/decisionApproaches.json` and will be
|
||||
* replaced with DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../../../components/utility/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import type { InfoMessageBoxItem } from "../../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../../components/utility/CardStack/CardStack.types";
|
||||
@@ -31,110 +32,9 @@ import {
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
import ApplicableScopeField from "../../components/ApplicableScopeField";
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
const CONSENSUS_LEVEL_MAX = 100;
|
||||
const CONSENSUS_LEVEL_STEP = 5;
|
||||
const CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
type RightRailModalSections = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
stepByStepInstructions: string;
|
||||
consensusLevel: number;
|
||||
objectionsDeadlocks: string;
|
||||
};
|
||||
|
||||
function AddDecisionApproachModalContent({
|
||||
approachCardId,
|
||||
}: {
|
||||
approachCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const da = m.create.customRule.decisionApproaches;
|
||||
const method = da.methods.find((entry) => entry.id === approachCardId);
|
||||
const modalSections = method?.sections;
|
||||
const defaults: RightRailModalSections = {
|
||||
corePrinciple: modalSections?.corePrinciple ?? "",
|
||||
applicableScope: modalSections?.applicableScope ?? [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: modalSections?.stepByStepInstructions ?? "",
|
||||
consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT,
|
||||
objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "",
|
||||
};
|
||||
|
||||
const [sections, setSections] = useState<RightRailModalSections>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
applicableScope: [...defaults.applicableScope],
|
||||
selectedApplicableScope: [...defaults.selectedApplicableScope],
|
||||
stepByStepInstructions: defaults.stepByStepInstructions,
|
||||
consensusLevel: defaults.consensusLevel,
|
||||
objectionsDeadlocks: defaults.objectionsDeadlocks,
|
||||
}));
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof RightRailModalSections>(
|
||||
key: K,
|
||||
value: RightRailModalSections[K],
|
||||
) => {
|
||||
markCreateFlowInteraction();
|
||||
setSections((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={da.sectionHeadings.corePrinciple}
|
||||
value={sections.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={da.sectionHeadings.applicableScope}
|
||||
addLabel={da.scopeAddButtonLabel}
|
||||
scopes={sections.applicableScope}
|
||||
selectedScopes={sections.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
sections.selectedApplicableScope.includes(scope)
|
||||
? sections.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...sections.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...sections.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={da.sectionHeadings.stepByStepInstructions}
|
||||
value={sections.stepByStepInstructions}
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={da.sectionHeadings.consensusLevel}
|
||||
value={sections.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => patch("consensusLevel", next)}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={da.sectionHeadings.objectionsDeadlocks}
|
||||
value={sections.objectionsDeadlocks}
|
||||
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { DecisionApproachDetailEntry } from "../../types";
|
||||
|
||||
export function DecisionApproachesScreen() {
|
||||
const m = useMessages();
|
||||
@@ -147,16 +47,11 @@ export function DecisionApproachesScreen() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<DecisionApproachDetailEntry | null>(null);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedDecisionApproachIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
da.messageBox.items.map((item) => ({
|
||||
@@ -219,12 +114,36 @@ export function DecisionApproachesScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): DecisionApproachDetailEntry => {
|
||||
const saved = state.decisionApproachDetailsById?.[id];
|
||||
if (saved) {
|
||||
return {
|
||||
...saved,
|
||||
applicableScope: [...saved.applicableScope],
|
||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||
};
|
||||
}
|
||||
return decisionApproachPresetFor(id);
|
||||
},
|
||||
[state.decisionApproachDetailsById],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction, seedDraft],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(next: DecisionApproachDetailEntry) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
@@ -236,37 +155,49 @@ export function DecisionApproachesScreen() {
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
if (!pendingCardId || !pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
selectedDecisionApproachIds: selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? da.confirmModal.title,
|
||||
description: method?.supportText ?? da.confirmModal.description,
|
||||
nextButtonText: da.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: da.confirmModal.title,
|
||||
description: da.confirmModal.description,
|
||||
nextButtonText: da.confirmModal.nextButtonText,
|
||||
};
|
||||
}
|
||||
|
||||
const method = methodById.get(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? da.confirmModal.title,
|
||||
description: method?.supportText ?? da.confirmModal.description,
|
||||
nextButtonText: da.addApproach.nextButtonText,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
@@ -315,10 +246,11 @@ export function DecisionApproachesScreen() {
|
||||
showBackButton={false}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddDecisionApproachModalContent
|
||||
{pendingCardId && pendingDraft ? (
|
||||
<DecisionApproachEditFields
|
||||
key={pendingCardId}
|
||||
approachCardId={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import TextArea from "../../../../components/controls/TextArea";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
import ContentLockup from "../../../../components/type/ContentLockup";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CoreValueDetailEntry,
|
||||
} from "../../types";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { CoreValueEditFields } from "../../components/methodEditFields";
|
||||
|
||||
const MAX_CORE_VALUES = 5;
|
||||
|
||||
@@ -96,6 +99,8 @@ function snapshotRowsToChipOptions(
|
||||
}));
|
||||
}
|
||||
|
||||
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
||||
|
||||
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
|
||||
export function CoreValuesSelectScreen() {
|
||||
const m = useMessages();
|
||||
@@ -122,8 +127,7 @@ export function CoreValuesSelectScreen() {
|
||||
null,
|
||||
);
|
||||
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||
const [draftMeaning, setDraftMeaning] = useState("");
|
||||
const [draftSignals, setDraftSignals] = useState("");
|
||||
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||
@@ -158,16 +162,16 @@ export function CoreValuesSelectScreen() {
|
||||
|
||||
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
|
||||
const getPresetTexts = useCallback(
|
||||
(valueLabel: string): { meaning: string; signals: string } => {
|
||||
(valueLabel: string): CoreValueDetailEntry => {
|
||||
const row = presets.find((p) => p.label === valueLabel);
|
||||
if (!row) return { meaning: "", signals: "" };
|
||||
if (!row) return EMPTY_DETAIL;
|
||||
return { meaning: row.meaning, signals: row.signals };
|
||||
},
|
||||
[presets],
|
||||
);
|
||||
|
||||
const getInitialTexts = useCallback(
|
||||
(chipId: string, valueLabel: string) => {
|
||||
(chipId: string, valueLabel: string): CoreValueDetailEntry => {
|
||||
const saved = state.coreValueDetailsByChipId?.[chipId];
|
||||
const preset = getPresetTexts(valueLabel);
|
||||
return {
|
||||
@@ -180,9 +184,7 @@ export function CoreValuesSelectScreen() {
|
||||
|
||||
const openModal = useCallback(
|
||||
(chipId: string, session: ModalSession, valueLabel: string) => {
|
||||
const initial = getInitialTexts(chipId, valueLabel);
|
||||
setDraftMeaning(initial.meaning);
|
||||
setDraftSignals(initial.signals);
|
||||
setDraft(getInitialTexts(chipId, valueLabel));
|
||||
setActiveModalChipId(chipId);
|
||||
setModalSession(session);
|
||||
markCreateFlowInteraction();
|
||||
@@ -190,6 +192,14 @@ export function CoreValuesSelectScreen() {
|
||||
[getInitialTexts, markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(next: CoreValueDetailEntry) => {
|
||||
markCreateFlowInteraction();
|
||||
setDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (activeModalChipId && modalSession === "pending") {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
@@ -208,19 +218,17 @@ export function CoreValuesSelectScreen() {
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
[activeModalChipId]: {
|
||||
meaning: draftMeaning,
|
||||
signals: draftSignals,
|
||||
},
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [
|
||||
activeModalChipId,
|
||||
draftMeaning,
|
||||
draftSignals,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
state.coreValueDetailsByChipId,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
@@ -374,26 +382,7 @@ export function CoreValuesSelectScreen() {
|
||||
nextButtonText={detailModal.addValueButton}
|
||||
ariaLabel={modalChipLabel || "Core value details"}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||
<TextArea
|
||||
label={detailModal.meaningLabel}
|
||||
showHelpIcon
|
||||
appearance="embedded"
|
||||
size="medium"
|
||||
value={draftMeaning}
|
||||
onChange={(e) => setDraftMeaning(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<TextArea
|
||||
label={detailModal.signalsLabel}
|
||||
showHelpIcon
|
||||
appearance="embedded"
|
||||
size="medium"
|
||||
value={draftSignals}
|
||||
onChange={(e) => setDraftSignals(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<CoreValueEditFields value={draft} onChange={handleDraftChange} />
|
||||
</Create>
|
||||
)}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
|
||||
@@ -50,6 +50,41 @@ export type CoreValueDetailEntry = {
|
||||
signals: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-chip edited sections written by the `final-review` edit modal and
|
||||
* merged back onto presets at publish time. Shapes mirror the custom-rule
|
||||
* add-method modals (see `app/(app)/create/screens/card/*`) so the same
|
||||
* field widgets can render both surfaces.
|
||||
*/
|
||||
export type CommunicationMethodDetailEntry = {
|
||||
corePrinciple: string;
|
||||
logisticsAdmin: string;
|
||||
codeOfConduct: string;
|
||||
};
|
||||
|
||||
export type MembershipMethodDetailEntry = {
|
||||
eligibility: string;
|
||||
joiningProcess: string;
|
||||
expectations: string;
|
||||
};
|
||||
|
||||
export type DecisionApproachDetailEntry = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
stepByStepInstructions: string;
|
||||
consensusLevel: number;
|
||||
objectionsDeadlocks: string;
|
||||
};
|
||||
|
||||
export type ConflictManagementDetailEntry = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
processProtocol: string;
|
||||
restorationFallbacks: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flow state for inputs across create-flow steps.
|
||||
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||
@@ -93,6 +128,22 @@ export interface CreateFlowState {
|
||||
selectedDecisionApproachIds?: string[];
|
||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||
selectedConflictManagementIds?: string[];
|
||||
/**
|
||||
* User edits from the `final-review` edit modal, keyed by preset method id
|
||||
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
|
||||
* stored rule reflects the author's customizations. Edits persist to the
|
||||
* anonymous localStorage draft and signed-in server draft automatically.
|
||||
*/
|
||||
communicationMethodDetailsById?: Record<
|
||||
string,
|
||||
CommunicationMethodDetailEntry
|
||||
>;
|
||||
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
|
||||
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
|
||||
conflictManagementDetailsById?: Record<
|
||||
string,
|
||||
ConflictManagementDetailEntry
|
||||
>;
|
||||
/**
|
||||
* Set when a user picks a template (Customize or Use without changes) before
|
||||
* completing the community stage. The community-review screen consumes this
|
||||
|
||||
@@ -336,6 +336,55 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
---
|
||||
|
||||
## Ticket 18 — Invite stakeholders (email) from `confirm-stakeholders` step
|
||||
|
||||
**Depends on:** **Ticket 3 / CR-74** + **Ticket 4 / CR-75** (magic-link + session) for per-stakeholder identity, **Ticket 6 / CR-77** (publish) so a `PublishedRule` exists to invite people to, **Ticket 2 / CR-73** (`CreateFlowState` validation) to land the persisted stakeholder shape.
|
||||
|
||||
**Server / admin:** **Same as CR-74** — dev works against Mailhog / dev-log; **staging/prod** reuses the **SMTP** + **SPF/DKIM** already set up for magic-link email.
|
||||
|
||||
**Goal:** Turn the [`confirm-stakeholders`](../../app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx) step from a local chip list into a real **stakeholder invite** feature. Today the screen only collects in-memory `ChipOption[]` labels with no email, no persistence, no notification. Product copy in [`messages/en/create/reviewAndComplete/confirmStakeholders.json`](../../messages/en/create/reviewAndComplete/confirmStakeholders.json) already promises that "Adding people at this step will **invite them** to see your proposed CommunityRule and make their own proposals" — we need the feature behind that promise.
|
||||
|
||||
**Context:** **No Figma hand-off or product direction yet** for the invite mechanic. **Email is the working assumption** (magic-link parity) but could become email + copy-link, in-app invite, SMS, etc. `CreateFlowState` has no structured `stakeholders` field and there is no `RuleStakeholder` / `RuleInvite` Prisma model. Leaving the step as-is ships a copy promise we do not keep; this ticket is the container for closing that gap once the design + product brief lands.
|
||||
|
||||
**Open product questions (block implementation until resolved):**
|
||||
|
||||
1. Invite channel: email only, email + shareable link, or in-app (account required)?
|
||||
2. Does accepting an invite require a CommunityRule account (magic-link), or can stakeholders read / propose anonymously?
|
||||
3. Per-stakeholder permissions: view-only vs. propose-changes vs. co-owner? Default?
|
||||
4. Timing: invites sent on **publish** (after `POST /api/rules`) or at **confirm-stakeholders** save? How are post-publish additions handled?
|
||||
5. Revocation / re-send UX and rate limits (reuse magic-link per-IP limiter or a new per-rule limiter?).
|
||||
6. Notification copy + sender identity (reuse `SMTP_FROM` or dedicated sender).
|
||||
|
||||
**Implementation sketch (subject to product sign-off):**
|
||||
|
||||
1. **Product + design pass** to answer the questions above and produce Figma for the stakeholder row (email field, validation, resend/remove affordances, empty state).
|
||||
2. **Schema:** Add a structured `stakeholders: StakeholderInvite[]` to `CreateFlowState` (Ticket 2 pattern) and/or a Prisma `RuleStakeholder` (`id`, `ruleId`, `email`, `role`, `invitedAt`, `acceptedAt`, `revokedAt`, `tokenHash?`). Decide draft vs. published semantics.
|
||||
3. **API:** Likely `POST /api/rules/:id/stakeholders` (invite), `DELETE /api/rules/:id/stakeholders/:id` (revoke), `POST /api/rules/:id/stakeholders/:id/resend`. Validate with Zod; align error shape with **Ticket 13 / CR-84**.
|
||||
4. **Mail:** Reuse [`lib/server/mail.ts`](../../lib/server/mail.ts) + Mailhog/dev-log pattern from **CR-74**; per-stakeholder hashed token akin to `MagicLinkToken` if acceptance requires identity.
|
||||
5. **UI:** Replace free-text chips with `email` + optional `label` input, client-side email validation, inline errors (invalid email, 429 `retryAfterMs`, duplicate). Use `markCreateFlowInteraction()` per the create-flow guardrails in [`.cursor/rules/create-flow.mdc`](../../.cursor/rules/create-flow.mdc).
|
||||
6. **i18n:** Expand [`confirmStakeholders.json`](../../messages/en/create/reviewAndComplete/confirmStakeholders.json) with invite copy (send button, error strings, resend, revoke confirm).
|
||||
7. **Tests:** Schema + route unit tests; Vitest component tests for the form; optional E2E once publish is stable.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Real-time collaboration / editing by stakeholders.
|
||||
- Threaded proposals / comments UI (future product work).
|
||||
- Notifications beyond the initial invite (digest emails, reminders).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Product + design brief answering the open questions, linked from the Linear issue.
|
||||
- [ ] `CreateFlowState.stakeholders` (or equivalent) is typed, validated server-side, and persists across draft save / resume.
|
||||
- [ ] An invited stakeholder receives the chosen notification (email in the default proposal) in local dev via Mailhog / dev-log, same pattern as magic-link.
|
||||
- [ ] Invalid / duplicate / rate-limited invites show clear, accessible errors; no silent failures.
|
||||
- [ ] Copy in [`confirmStakeholders.json`](../../messages/en/create/reviewAndComplete/confirmStakeholders.json) matches the shipped capability (no "will invite" promise without implementation).
|
||||
|
||||
**Files (expected):** [`app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx`](../../app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx), [`messages/en/create/reviewAndComplete/confirmStakeholders.json`](../../messages/en/create/reviewAndComplete/confirmStakeholders.json), [`app/(app)/create/types.ts`](../../app/(app)/create/types.ts), [`prisma/schema.prisma`](../../prisma/schema.prisma), new `app/api/rules/[id]/stakeholders/*`, [`lib/server/mail.ts`](../../lib/server/mail.ts), [`lib/server/validation/`](../../lib/server/validation/), tests under `tests/`.
|
||||
|
||||
**Linear:** [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) (**Backlog**). **Parallel** to the CR-72 → CR-83 chain; unblocked to start the product/design brief now — implementation waits on **CR-74 / CR-75 / CR-77 / CR-73**.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
|
||||
|
||||
**Depends on:** none (orthogonal).
|
||||
@@ -541,14 +590,15 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
| 15 | 15 | Profile + account (Figma profile) |
|
||||
| 16 | 16 | Template matrix + xlsx ingestion |
|
||||
| 17 | 17 | Canon create-flow (custom path) |
|
||||
| 18 | 18 | Stakeholder invites (confirm-stakeholders) |
|
||||
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Ticket 17** (**[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) and tracks UX/code alignment (progress bar, resume URL, `[step]` cleanup); **parallel** to publish and templates. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**.
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Ticket 17** (**[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) and tracks UX/code alignment (progress bar, resume URL, `[step]` cleanup); **parallel** to publish and templates. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation.
|
||||
|
||||
---
|
||||
|
||||
## Linear (Community-rule team)
|
||||
|
||||
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), **CR-89** / Ticket 17 (canon create-flow + implementation gaps), not in the CR-72–83 sequence.
|
||||
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), **CR-89** / Ticket 17 (canon create-flow + implementation gaps), **CR-90** / Ticket 18 (stakeholder invites — product/design brief unblocked; implementation waits on **CR-73** / **CR-74** / **CR-75** / **CR-77**), none in the CR-72–83 sequence.
|
||||
|
||||
| Doc ticket | Linear | Title (short) |
|
||||
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
@@ -569,6 +619,7 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts +
|
||||
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
|
||||
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion |
|
||||
| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) |
|
||||
| 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -76,6 +76,36 @@ function buildCoreValuePrefill(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of {@link buildTemplateCustomizePrefill} that pulls *only* the
|
||||
* Values section out of a template body. Used by the "Use without changes"
|
||||
* handler so the verbatim template flow still seeds
|
||||
* `coreValuesChipsSnapshot` + `selectedCoreValueIds` — without that, the
|
||||
* final-review screen has no per-chip ids to attach edits to and falls
|
||||
* back to the read-only chip modal for values.
|
||||
*
|
||||
* Returns an empty object when the body is malformed or has no Values
|
||||
* section.
|
||||
*/
|
||||
export function buildCoreValuesPrefillFromTemplateBody(
|
||||
body: unknown,
|
||||
): Partial<CreateFlowState> {
|
||||
if (!body || typeof body !== "object") return {};
|
||||
const sections = (body as { sections?: unknown }).sections;
|
||||
if (!Array.isArray(sections)) return {};
|
||||
|
||||
for (const raw of sections) {
|
||||
if (!isTemplateSection(raw)) continue;
|
||||
const key = normaliseCategoryKey(raw.categoryName as string);
|
||||
if (key !== "values" && key !== "corevalues") continue;
|
||||
const titles = entryTitles(raw.entries);
|
||||
if (titles.length === 0) continue;
|
||||
return buildCoreValuePrefill(titles);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a curated template `body` (DB shape — `sections[]` with `categoryName`
|
||||
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
buildCoreValuesForDocument,
|
||||
parseSectionsFromCreateFlowState,
|
||||
} from "./buildPublishPayload";
|
||||
import {
|
||||
templateCategoryToGroupKey,
|
||||
type TemplateFacetGroupKey,
|
||||
} from "./templateReviewMapping";
|
||||
|
||||
/**
|
||||
* Chip row shape shared with `messages/en/create/reviewAndComplete/finalReview.json`
|
||||
@@ -15,6 +19,31 @@ import {
|
||||
*/
|
||||
export type FinalReviewCategoryRow = { name: string; chips: string[] };
|
||||
|
||||
/**
|
||||
* Per-chip details needed to open an *editable* chip modal on the final-review
|
||||
* screen. `overrideKey` is the stable id we use to look up / write user edits
|
||||
* in `CreateFlowState`:
|
||||
*
|
||||
* - `coreValues` → the chip id from `coreValuesChipsSnapshot`
|
||||
* (round-trips via `coreValueDetailsByChipId`).
|
||||
* - Method groups → the preset method id from
|
||||
* `messages/en/create/customRule/{group}.json` `methods[].id`
|
||||
* (round-trips via `{group}MethodDetailsById`).
|
||||
* - Unknown / template-only chips → `null` (modal falls back to read-only).
|
||||
*/
|
||||
export type FinalReviewChipEntry = {
|
||||
label: string;
|
||||
groupKey: TemplateFacetGroupKey | null;
|
||||
overrideKey: string | null;
|
||||
};
|
||||
|
||||
/** Detailed row paired with per-chip override + group metadata. */
|
||||
export type FinalReviewCategoryRowDetailed = {
|
||||
name: string;
|
||||
groupKey: TemplateFacetGroupKey | null;
|
||||
entries: FinalReviewChipEntry[];
|
||||
};
|
||||
|
||||
/** Category labels supplied by the caller (pulled from localized messages). */
|
||||
export type FinalReviewCategoryNames = {
|
||||
values: string;
|
||||
@@ -41,24 +70,166 @@ function readMethodsArray(source: unknown): MethodPreset[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function labelsFromIds(
|
||||
/**
|
||||
* Resolve an ordered list of preset ids to `{label, id}` entries, filtering
|
||||
* missing/duplicate labels. The id is returned alongside so callers can key
|
||||
* per-chip overrides by the stable preset id (e.g. `"signal"`) even after
|
||||
* labels change through localization.
|
||||
*/
|
||||
function entriesFromIds(
|
||||
ids: readonly string[] | undefined,
|
||||
methods: readonly MethodPreset[],
|
||||
): string[] {
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): FinalReviewChipEntry[] {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const byId = new Map(methods.map((m) => [m.id, m.label] as const));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const out: FinalReviewChipEntry[] = [];
|
||||
for (const id of ids) {
|
||||
const label = byId.get(id);
|
||||
if (typeof label !== "string" || label.length === 0) continue;
|
||||
if (seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
out.push(label);
|
||||
out.push({ label, groupKey, overrideKey: id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-lookup a preset id by label for the template-sections path, where
|
||||
* chips carry entry titles rather than ids. Used to recover an overrideKey
|
||||
* for chips the user sees on a "Use without changes" template review.
|
||||
*/
|
||||
function overrideKeyForLabel(
|
||||
label: string,
|
||||
methods: readonly MethodPreset[],
|
||||
): string | null {
|
||||
const normalized = label.trim().toLowerCase();
|
||||
if (normalized.length === 0) return null;
|
||||
for (const m of methods) {
|
||||
if (m.label.trim().toLowerCase() === normalized) return m.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function methodsForGroup(
|
||||
groupKey: TemplateFacetGroupKey | null,
|
||||
): readonly MethodPreset[] {
|
||||
switch (groupKey) {
|
||||
case "communication":
|
||||
return readMethodsArray(communicationMessages);
|
||||
case "membership":
|
||||
return readMethodsArray(membershipMessages);
|
||||
case "decisionApproaches":
|
||||
return readMethodsArray(decisionApproachesMessages);
|
||||
case "conflictManagement":
|
||||
return readMethodsArray(conflictManagementMessages);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed builder: same logic as {@link buildFinalReviewCategoriesFromState}
|
||||
* but each chip is returned with its `overrideKey` + `groupKey` so the
|
||||
* final-review screen can wire chip clicks to an editable modal that
|
||||
* round-trips writes back into `CreateFlowState`.
|
||||
*/
|
||||
export function buildFinalReviewCategoryRowsDetailed(
|
||||
state: CreateFlowState,
|
||||
names: FinalReviewCategoryNames,
|
||||
): FinalReviewCategoryRowDetailed[] {
|
||||
const sections = parseSectionsFromCreateFlowState(state);
|
||||
const coreValues = buildCoreValuesForDocument(state);
|
||||
|
||||
const coreValueEntries: FinalReviewChipEntry[] = coreValues.map((r) => ({
|
||||
label: r.label,
|
||||
groupKey: "coreValues" as const,
|
||||
overrideKey: r.chipId,
|
||||
}));
|
||||
|
||||
// Use-without-changes / pre-rendered template body.
|
||||
if (sections.length > 0) {
|
||||
const rows: FinalReviewCategoryRowDetailed[] = [];
|
||||
|
||||
// Always prefer the chip-snapshot derived entries when present so the
|
||||
// values row uses stable per-chip ids the edit modal can attach to.
|
||||
// We then skip any Values section in the template body to avoid
|
||||
// duplicating the row (the snapshot already represents the same data).
|
||||
if (coreValueEntries.length > 0) {
|
||||
rows.push({
|
||||
name: names.values,
|
||||
groupKey: "coreValues",
|
||||
entries: coreValueEntries,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of sections) {
|
||||
const groupKey = templateCategoryToGroupKey(s.categoryName);
|
||||
if (groupKey === "coreValues" && coreValueEntries.length > 0) continue;
|
||||
const methods = methodsForGroup(groupKey);
|
||||
const entries: FinalReviewChipEntry[] = [];
|
||||
for (const e of s.entries) {
|
||||
const title = e.title.trim();
|
||||
if (title.length === 0) continue;
|
||||
// For the Values section inside template bodies we can't recover a
|
||||
// stable chip id (no snapshot), so override is unavailable — the
|
||||
// modal will render read-only. Method sections fall back to label
|
||||
// → preset-id resolution so matching titles stay editable.
|
||||
let overrideKey: string | null = null;
|
||||
if (groupKey && groupKey !== "coreValues") {
|
||||
overrideKey = overrideKeyForLabel(title, methods);
|
||||
}
|
||||
entries.push({ label: title, groupKey, overrideKey });
|
||||
}
|
||||
if (entries.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, groupKey, entries });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const rows: FinalReviewCategoryRowDetailed[] = [
|
||||
{ name: names.values, groupKey: "coreValues", entries: coreValueEntries },
|
||||
{
|
||||
name: names.communication,
|
||||
groupKey: "communication",
|
||||
entries: entriesFromIds(
|
||||
state.selectedCommunicationMethodIds,
|
||||
methodsForGroup("communication"),
|
||||
"communication",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.membership,
|
||||
groupKey: "membership",
|
||||
entries: entriesFromIds(
|
||||
state.selectedMembershipMethodIds,
|
||||
methodsForGroup("membership"),
|
||||
"membership",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.decisions,
|
||||
groupKey: "decisionApproaches",
|
||||
entries: entriesFromIds(
|
||||
state.selectedDecisionApproachIds,
|
||||
methodsForGroup("decisionApproaches"),
|
||||
"decisionApproaches",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.conflict,
|
||||
groupKey: "conflictManagement",
|
||||
entries: entriesFromIds(
|
||||
state.selectedConflictManagementIds,
|
||||
methodsForGroup("conflictManagement"),
|
||||
"conflictManagement",
|
||||
),
|
||||
},
|
||||
];
|
||||
return rows.filter((r) => r.entries.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the final-review RuleCard category rows from the current
|
||||
* {@link CreateFlowState}.
|
||||
@@ -81,72 +252,8 @@ export function buildFinalReviewCategoriesFromState(
|
||||
state: CreateFlowState,
|
||||
names: FinalReviewCategoryNames,
|
||||
): FinalReviewCategoryRow[] {
|
||||
const sections = parseSectionsFromCreateFlowState(state);
|
||||
const coreValueLabels = buildCoreValuesForDocument(state).map((r) => r.label);
|
||||
|
||||
// Use-without-changes / pre-rendered template body: the sections array is
|
||||
// the source of truth. Collapse each section's entries to its titles; the
|
||||
// RuleCard category UI shows only labels, not per-entry body copy.
|
||||
if (sections.length > 0) {
|
||||
const rows: FinalReviewCategoryRow[] = [];
|
||||
|
||||
// If core values were also captured (e.g., the template surfaced both),
|
||||
// keep them up top for visual parity with the custom-rule flow. Otherwise
|
||||
// any `Values` section already inside `sections` covers the same ground.
|
||||
if (coreValueLabels.length > 0) {
|
||||
const hasValuesSection = sections.some(
|
||||
(s) => s.categoryName.toLowerCase() === names.values.toLowerCase(),
|
||||
);
|
||||
if (!hasValuesSection) {
|
||||
rows.push({ name: names.values, chips: coreValueLabels });
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of sections) {
|
||||
const chips = s.entries
|
||||
.map((e) => e.title.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
if (chips.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, chips });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const communicationMethods = readMethodsArray(communicationMessages);
|
||||
const membershipMethods = readMethodsArray(membershipMessages);
|
||||
const decisionApproachMethods = readMethodsArray(decisionApproachesMessages);
|
||||
const conflictManagementMethods = readMethodsArray(conflictManagementMessages);
|
||||
|
||||
const rows: FinalReviewCategoryRow[] = [
|
||||
{ name: names.values, chips: coreValueLabels },
|
||||
{
|
||||
name: names.communication,
|
||||
chips: labelsFromIds(
|
||||
state.selectedCommunicationMethodIds,
|
||||
communicationMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.membership,
|
||||
chips: labelsFromIds(
|
||||
state.selectedMembershipMethodIds,
|
||||
membershipMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.decisions,
|
||||
chips: labelsFromIds(
|
||||
state.selectedDecisionApproachIds,
|
||||
decisionApproachMethods,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: names.conflict,
|
||||
chips: labelsFromIds(
|
||||
state.selectedConflictManagementIds,
|
||||
conflictManagementMethods,
|
||||
),
|
||||
},
|
||||
];
|
||||
return rows.filter((r) => r.chips.length > 0);
|
||||
return buildFinalReviewCategoryRowsDetailed(state, names).map((r) => ({
|
||||
name: r.name,
|
||||
chips: r.entries.map((e) => e.label),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { CoreValueDetailEntry, CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
CoreValueDetailEntry,
|
||||
CreateFlowState,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../../app/(app)/create/types";
|
||||
import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
decisionApproachPresetFor,
|
||||
membershipPresetFor,
|
||||
methodLabelFor,
|
||||
} from "./finalReviewChipPresets";
|
||||
|
||||
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
@@ -52,6 +66,36 @@ export function buildCoreValuesForDocument(state: CreateFlowState): Array<{
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured per-group method selections emitted into `document.methodSelections`
|
||||
* at publish time. Each entry carries the preset id (stable key), display
|
||||
* label, and the fully-resolved section payload (override on top of preset).
|
||||
* Empty groups are omitted so downstream readers can iterate just the set
|
||||
* the author actually picked.
|
||||
*/
|
||||
export type PublishedMethodSelections = {
|
||||
communication?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: CommunicationMethodDetailEntry;
|
||||
}>;
|
||||
membership?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: MembershipMethodDetailEntry;
|
||||
}>;
|
||||
decisionApproaches?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: DecisionApproachDetailEntry;
|
||||
}>;
|
||||
conflictManagement?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sections: ConflictManagementDetailEntry;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BuildPublishPayloadResult =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -97,16 +141,93 @@ export function buildPublishPayload(
|
||||
}
|
||||
|
||||
const coreValues = buildCoreValuesForDocument(state);
|
||||
const methodSelections = buildMethodSelectionsForDocument(state);
|
||||
|
||||
const document: Record<string, unknown> = { sections, coreValues };
|
||||
if (hasAnyMethodSelection(methodSelections)) {
|
||||
document.methodSelections = methodSelections;
|
||||
}
|
||||
|
||||
if (summary !== undefined) {
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
summary,
|
||||
document: { sections, coreValues },
|
||||
};
|
||||
return { ok: true, title, summary, document };
|
||||
}
|
||||
return { ok: true, title, document: { sections, coreValues } };
|
||||
return { ok: true, title, document };
|
||||
}
|
||||
|
||||
function hasAnyMethodSelection(m: PublishedMethodSelections): boolean {
|
||||
return Boolean(
|
||||
m.communication?.length ||
|
||||
m.membership?.length ||
|
||||
m.decisionApproaches?.length ||
|
||||
m.conflictManagement?.length,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `selected*MethodIds` with any saved `{group}MethodDetailsById`
|
||||
* overrides authored on the final-review screen. Preset defaults from the
|
||||
* shipped `messages/en/create/customRule/*.json` seed any sub-fields the
|
||||
* user didn't edit so consumers of `document.methodSelections` always see
|
||||
* a complete payload per method.
|
||||
*/
|
||||
export function buildMethodSelectionsForDocument(
|
||||
state: CreateFlowState,
|
||||
): PublishedMethodSelections {
|
||||
const out: PublishedMethodSelections = {};
|
||||
|
||||
const commIds = state.selectedCommunicationMethodIds ?? [];
|
||||
if (commIds.length > 0) {
|
||||
out.communication = commIds.map((id) => {
|
||||
const preset = communicationPresetFor(id);
|
||||
const override = state.communicationMethodDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("communication", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const memIds = state.selectedMembershipMethodIds ?? [];
|
||||
if (memIds.length > 0) {
|
||||
out.membership = memIds.map((id) => {
|
||||
const preset = membershipPresetFor(id);
|
||||
const override = state.membershipMethodDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("membership", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const daIds = state.selectedDecisionApproachIds ?? [];
|
||||
if (daIds.length > 0) {
|
||||
out.decisionApproaches = daIds.map((id) => {
|
||||
const preset = decisionApproachPresetFor(id);
|
||||
const override = state.decisionApproachDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("decisionApproaches", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const cmIds = state.selectedConflictManagementIds ?? [];
|
||||
if (cmIds.length > 0) {
|
||||
out.conflictManagement = cmIds.map((id) => {
|
||||
const preset = conflictManagementPresetFor(id);
|
||||
const override = state.conflictManagementDetailsById?.[id];
|
||||
return {
|
||||
id,
|
||||
label: methodLabelFor("conflictManagement", id),
|
||||
sections: override ? { ...preset, ...override } : preset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read `document.sections` from a stored published payload for display. */
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import communicationMessages from "../../messages/en/create/customRule/communication.json";
|
||||
import conflictManagementMessages from "../../messages/en/create/customRule/conflictManagement.json";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
|
||||
import membershipMessages from "../../messages/en/create/customRule/membership.json";
|
||||
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
CoreValueDetailEntry,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../../app/(app)/create/types";
|
||||
|
||||
/**
|
||||
* Per-method preset defaults shipped in `messages/en/create/customRule/*.json`.
|
||||
* Used to seed the final-review edit modal when the user has no saved
|
||||
* override yet, and to merge onto overrides when emitting the published rule
|
||||
* document so every method carries a complete section payload even if the
|
||||
* author only edited a subset of fields.
|
||||
*/
|
||||
type CustomRuleMethodRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportText?: string;
|
||||
sections?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readMethodsArray(source: unknown): CustomRuleMethodRow[] {
|
||||
if (!source || typeof source !== "object") return [];
|
||||
const methods = (source as { methods?: unknown }).methods;
|
||||
if (!Array.isArray(methods)) return [];
|
||||
const out: CustomRuleMethodRow[] = [];
|
||||
for (const raw of methods) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || typeof o.label !== "string") continue;
|
||||
out.push({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
supportText:
|
||||
typeof o.supportText === "string" ? o.supportText : undefined,
|
||||
sections:
|
||||
o.sections && typeof o.sections === "object"
|
||||
? (o.sections as Record<string, unknown>)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: string[] = [];
|
||||
for (const v of value) {
|
||||
if (typeof v === "string") out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asNumberClamped(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function findMethod(
|
||||
source: unknown,
|
||||
id: string,
|
||||
): CustomRuleMethodRow | null {
|
||||
const rows = readMethodsArray(source);
|
||||
return rows.find((r) => r.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** Preset-default communication sections for a given method id. */
|
||||
export function communicationPresetFor(
|
||||
id: string,
|
||||
): CommunicationMethodDetailEntry {
|
||||
const method = findMethod(communicationMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
logisticsAdmin: asString(s.logisticsAdmin),
|
||||
codeOfConduct: asString(s.codeOfConduct),
|
||||
};
|
||||
}
|
||||
|
||||
export function membershipPresetFor(id: string): MembershipMethodDetailEntry {
|
||||
const method = findMethod(membershipMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
eligibility: asString(s.eligibility),
|
||||
joiningProcess: asString(s.joiningProcess),
|
||||
expectations: asString(s.expectations),
|
||||
};
|
||||
}
|
||||
|
||||
/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */
|
||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
export function decisionApproachPresetFor(
|
||||
id: string,
|
||||
): DecisionApproachDetailEntry {
|
||||
const method = findMethod(decisionApproachesMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
applicableScope: asStringArray(s.applicableScope),
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||
consensusLevel: asNumberClamped(
|
||||
s.consensusLevel,
|
||||
0,
|
||||
100,
|
||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||
),
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
}
|
||||
|
||||
export function conflictManagementPresetFor(
|
||||
id: string,
|
||||
): ConflictManagementDetailEntry {
|
||||
const method = findMethod(conflictManagementMessages, id);
|
||||
const s = method?.sections ?? {};
|
||||
return {
|
||||
corePrinciple: asString(s.corePrinciple),
|
||||
applicableScope: asStringArray(s.applicableScope),
|
||||
selectedApplicableScope: [],
|
||||
processProtocol: asString(s.processProtocol),
|
||||
restorationFallbacks: asString(s.restorationFallbacks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset meaning/signals for a core value chip. Mirrors `CoreValuesSelectScreen`'s
|
||||
* `getInitialTexts` so the final-review edit modal opens with the same
|
||||
* preset copy the select screen would have shown — without requiring the
|
||||
* user to have opened the select-screen modal first.
|
||||
*
|
||||
* Lookup order:
|
||||
* 1. Numeric chip id → 1-based index into `coreValues.json` `values[]`
|
||||
* (matches `CoreValuesSelectScreen` chip ids).
|
||||
* 2. Otherwise → empty (bespoke / template-derived chip with no preset).
|
||||
*/
|
||||
export function coreValuePresetFor(chipId: string): CoreValueDetailEntry {
|
||||
const values = (coreValuesMessages as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return { meaning: "", signals: "" };
|
||||
const idx = Number.parseInt(chipId, 10);
|
||||
if (!Number.isInteger(idx) || idx < 1 || idx > values.length) {
|
||||
return { meaning: "", signals: "" };
|
||||
}
|
||||
const row = values[idx - 1];
|
||||
if (!row || typeof row !== "object") return { meaning: "", signals: "" };
|
||||
const o = row as Record<string, unknown>;
|
||||
return {
|
||||
meaning: asString(o.meaning),
|
||||
signals: asString(o.signals),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve method preset label by id for a given group (localized display). */
|
||||
export function methodLabelFor(
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
id: string,
|
||||
): string {
|
||||
const source =
|
||||
groupKey === "communication"
|
||||
? communicationMessages
|
||||
: groupKey === "membership"
|
||||
? membershipMessages
|
||||
: groupKey === "decisionApproaches"
|
||||
? decisionApproachesMessages
|
||||
: groupKey === "conflictManagement"
|
||||
? conflictManagementMessages
|
||||
: null;
|
||||
if (!source) return "";
|
||||
const method = findMethod(source, id);
|
||||
return method?.label ?? "";
|
||||
}
|
||||
@@ -25,6 +25,39 @@ const coreValueDetailEntrySchema = z.object({
|
||||
signals: z.string().max(8000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Final-review edit modal details per group, merged onto preset defaults at
|
||||
* publish time. Shapes mirror the custom-rule add-method modals.
|
||||
*/
|
||||
const communicationMethodDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
logisticsAdmin: z.string().max(8000),
|
||||
codeOfConduct: z.string().max(8000),
|
||||
});
|
||||
|
||||
const membershipMethodDetailEntrySchema = z.object({
|
||||
eligibility: z.string().max(8000),
|
||||
joiningProcess: z.string().max(8000),
|
||||
expectations: z.string().max(8000),
|
||||
});
|
||||
|
||||
const decisionApproachDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
stepByStepInstructions: z.string().max(8000),
|
||||
consensusLevel: z.number().int().min(0).max(100),
|
||||
objectionsDeadlocks: z.string().max(8000),
|
||||
});
|
||||
|
||||
const conflictManagementDetailEntrySchema = z.object({
|
||||
corePrinciple: z.string().max(8000),
|
||||
applicableScope: z.array(z.string().max(2000)).max(50),
|
||||
selectedApplicableScope: z.array(z.string().max(2000)).max(50),
|
||||
processProtocol: z.string().max(8000),
|
||||
restorationFallbacks: z.string().max(8000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||
*/
|
||||
@@ -67,6 +100,18 @@ export const createFlowStateSchema = z
|
||||
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
|
||||
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
|
||||
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
|
||||
communicationMethodDetailsById: z
|
||||
.record(communicationMethodDetailEntrySchema)
|
||||
.optional(),
|
||||
membershipMethodDetailsById: z
|
||||
.record(membershipMethodDetailEntrySchema)
|
||||
.optional(),
|
||||
decisionApproachDetailsById: z
|
||||
.record(decisionApproachDetailEntrySchema)
|
||||
.optional(),
|
||||
conflictManagementDetailsById: z
|
||||
.record(conflictManagementDetailEntrySchema)
|
||||
.optional(),
|
||||
pendingTemplateAction: z
|
||||
.object({
|
||||
slug: z.string().max(200),
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
|
||||
"ruleCardTitleFallback": "Your community",
|
||||
"ruleCardDescriptionFallback": "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.",
|
||||
"chipEditModal": {
|
||||
"saveButton": "Save",
|
||||
"readOnlyCloseButton": "Close",
|
||||
"readOnlyNote": "Details for this entry aren't editable yet."
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"name": "Values",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
within,
|
||||
waitFor,
|
||||
} from "../utils/test-utils";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Mounts the screen with optional starting state and exposes the latest
|
||||
* `state` to the test harness so we can assert the persistence side of
|
||||
* the Add Platform flow without driving the wizard's Next chain.
|
||||
*/
|
||||
const EMPTY_STATE: CreateFlowState = {};
|
||||
|
||||
function ScreenWithStateProbe({
|
||||
onState,
|
||||
initial = EMPTY_STATE,
|
||||
}: {
|
||||
onState: (_state: CreateFlowState) => void;
|
||||
initial?: CreateFlowState;
|
||||
}) {
|
||||
const { state, replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState(initial);
|
||||
}, [replaceState, initial]);
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [state, onState]);
|
||||
return <CommunicationMethodsScreen />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the persistence half of the Add-Platform flow that lets the
|
||||
* final-review chip edit modal start from a known seed instead of always
|
||||
* snapping back to preset copy. See {@link CommunicationMethodEditFields}
|
||||
* and `buildPublishPayload` for the read side.
|
||||
*/
|
||||
describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
it("seeds the modal from preset and persists edits + selection on Confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textareas = within(dialog).getAllByRole("textbox");
|
||||
expect(textareas.length).toBe(3);
|
||||
// Preset corePrinciple must seed into the first textarea so the user
|
||||
// edits a real starting point rather than an empty field.
|
||||
expect((textareas[0] as HTMLTextAreaElement).value.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
fireEvent.change(textareas[0], { target: { value: "Custom principle" } });
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: "Add Platform" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(latest.selectedCommunicationMethodIds).toContain("signal");
|
||||
});
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Custom principle");
|
||||
});
|
||||
|
||||
it("does not persist edits when the modal closes without Confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(latest.selectedCommunicationMethodIds ?? []).not.toContain("signal");
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-seeds the modal from a saved override when reopening the same chip", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={{
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
communicationMethodDetailsById: {
|
||||
signal: {
|
||||
corePrinciple: "Saved principle",
|
||||
logisticsAdmin: "Saved logistics",
|
||||
codeOfConduct: "Saved coc",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
void latest;
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textareas = within(dialog).getAllByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement[];
|
||||
expect(textareas[0].value).toBe("Saved principle");
|
||||
expect(textareas[1].value).toBe("Saved logistics");
|
||||
expect(textareas[2].value).toBe("Saved coc");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
@@ -8,6 +9,30 @@ import {
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
/**
|
||||
* Mounts the screen with a Customize-style preset selection and exposes the
|
||||
* latest `state` to the test via `onState`. Used by the edit-modal save
|
||||
* semantics suite below to assert what the user's edits actually persist
|
||||
* (or don't, on close).
|
||||
*/
|
||||
function FinalReviewWithStateProbe({
|
||||
onState,
|
||||
initial,
|
||||
}: {
|
||||
onState: (_state: CreateFlowState) => void;
|
||||
initial: CreateFlowState;
|
||||
}) {
|
||||
const { state, replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState(initial);
|
||||
}, [replaceState, initial]);
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [state, onState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
|
||||
const FALLBACK_CARD_TITLE = "Your community";
|
||||
const FALLBACK_CARD_DESCRIPTION_SNIPPET =
|
||||
@@ -143,3 +168,221 @@ describe("FinalReviewScreen — prefilled selections", () => {
|
||||
expect(screen.queryByText("Consciousness")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FinalReviewScreen — chip detail modal", () => {
|
||||
it("opens the read-only detail modal when a chip is clicked, matching the preset copy", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
|
||||
const signalChip = await screen.findByRole("button", { name: "Signal" });
|
||||
fireEvent.click(signalChip);
|
||||
|
||||
// Modal subtitle is the `supportText` from communication.json for the
|
||||
// "signal" method — proves the chip click resolved the correct preset
|
||||
// and reused the TemplateChipDetailModal's by-label lookup.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Encrypted messaging for high-security, private coordination\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
// Core-principle section heading is shared copy from the same messages
|
||||
// file; assert it renders to confirm the modal body hydrated.
|
||||
expect(
|
||||
screen.getAllByText(/core principle/i).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("opens a core-values chip with the matching preset meaning/signals", async () => {
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
render(<CoreValuesHarness />);
|
||||
|
||||
const chip = await screen.findByRole("button", { name: "Accessibility" });
|
||||
fireEvent.click(chip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/what does this value mean to your group\?/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/signals of violation/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip (parity with method chips)", async () => {
|
||||
// Customize / plain custom-rule path: snapshot is set, sections is not.
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
render(<CoreValuesHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Close" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip in the use-without-changes flow", async () => {
|
||||
// Mirrors the post-fix payload from `handleUseTemplateWithoutChanges`:
|
||||
// template Values section is stripped from `sections`, snapshot +
|
||||
// selected ids are seeded so the chip carries an `overrideKey`.
|
||||
function UseWithoutChangesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
title: "Oak Park Commons",
|
||||
// Values section deliberately absent — apply handler scrubs it.
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <FinalReviewScreen />;
|
||||
}
|
||||
render(<UseWithoutChangesHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Save semantics for {@link FinalReviewChipEditModal}. Mirrors the
|
||||
* "edits ride along to publish" promise documented in the modal:
|
||||
*
|
||||
* 1. Save starts disabled (no edits yet → nothing to persist).
|
||||
* 2. Editing any field flips Save on; clicking it writes the typed
|
||||
* `{group}MethodDetailsById[id]` entry into create-flow state and
|
||||
* closes the modal.
|
||||
* 3. Closing without Save discards every typed change.
|
||||
*/
|
||||
describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
const baseSelections: CreateFlowState = {
|
||||
title: "Oak Park Commons",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
};
|
||||
|
||||
it("starts with the Save button disabled until the user edits a field", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={baseSelections}
|
||||
/>,
|
||||
);
|
||||
void latest;
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const saveButton = within(dialog).getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("writes edits into communicationMethodDetailsById when Save is clicked", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={baseSelections}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Edited principle");
|
||||
});
|
||||
});
|
||||
|
||||
it("discards typed edits when the modal closes without Save", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={baseSelections}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTemplateCustomizePrefill } from "../../lib/create/applyTemplatePrefill";
|
||||
import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../lib/create/applyTemplatePrefill";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
|
||||
function coreValuePresetId(label: string): string {
|
||||
@@ -107,3 +110,46 @@ describe("buildTemplateCustomizePrefill", () => {
|
||||
expect(prefill).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCoreValuesPrefillFromTemplateBody", () => {
|
||||
it("returns {} for malformed bodies", () => {
|
||||
expect(buildCoreValuesPrefillFromTemplateBody(null)).toEqual({});
|
||||
expect(buildCoreValuesPrefillFromTemplateBody({})).toEqual({});
|
||||
expect(
|
||||
buildCoreValuesPrefillFromTemplateBody({ sections: "nope" }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when the body has no Values section", () => {
|
||||
expect(
|
||||
buildCoreValuesPrefillFromTemplateBody({
|
||||
sections: [
|
||||
{ categoryName: "Communication", entries: [{ title: "Signal" }] },
|
||||
],
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("seeds the snapshot + selected ids from the Values section only", () => {
|
||||
const prefill = buildCoreValuesPrefillFromTemplateBody({
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Consensus", body: "" },
|
||||
{ title: "Community Care", body: "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const selected = prefill.selectedCoreValueIds ?? [];
|
||||
expect(selected).toContain(coreValuePresetId("Consensus"));
|
||||
expect(selected).toContain(coreValuePresetId("Community Care"));
|
||||
// Methods should not be touched by the values-only helper.
|
||||
expect(prefill.selectedCommunicationMethodIds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,11 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate Values when sections already includes one", () => {
|
||||
it("prefers the chip snapshot over a duplicated Values section in sections", () => {
|
||||
// The use-without-changes handler now strips Values from `sections` and
|
||||
// seeds the snapshot, but legacy drafts persisted before that fix can
|
||||
// still arrive here with both sources present. The snapshot wins so the
|
||||
// final-review chip modal can attach edits via the per-chip id.
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
@@ -129,7 +133,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Values", chips: ["Consciousness"] },
|
||||
{ name: "Values", chips: ["Accessibility"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,89 @@ describe("buildPublishPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPublishPayload — methodSelections", () => {
|
||||
it("omits document.methodSelections when no method group is selected", () => {
|
||||
const r = buildPublishPayload({ title: "T" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.document.methodSelections).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits preset-only sections when a method is selected without an override", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const ms = r.document.methodSelections as
|
||||
| Record<string, Array<Record<string, unknown>>>
|
||||
| undefined;
|
||||
expect(ms).toBeDefined();
|
||||
expect(ms?.communication?.length).toBe(1);
|
||||
const entry = ms?.communication?.[0] as {
|
||||
id: string;
|
||||
label: string;
|
||||
sections: { corePrinciple: string };
|
||||
};
|
||||
expect(entry.id).toBe("signal");
|
||||
expect(entry.label).toBe("Signal");
|
||||
// Preset corePrinciple is non-empty for `signal` in the shipped messages
|
||||
// file — proves we read presets when no override is present.
|
||||
expect(entry.sections.corePrinciple.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("merges override on top of preset for the selected method", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
communicationMethodDetailsById: {
|
||||
signal: {
|
||||
corePrinciple: "OVERRIDE PRINCIPLE",
|
||||
logisticsAdmin: "OVERRIDE LOGISTICS",
|
||||
codeOfConduct: "OVERRIDE COC",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const ms = r.document.methodSelections as
|
||||
| Record<string, Array<Record<string, unknown>>>
|
||||
| undefined;
|
||||
const entry = ms?.communication?.[0] as {
|
||||
sections: {
|
||||
corePrinciple: string;
|
||||
logisticsAdmin: string;
|
||||
codeOfConduct: string;
|
||||
};
|
||||
};
|
||||
expect(entry.sections.corePrinciple).toBe("OVERRIDE PRINCIPLE");
|
||||
expect(entry.sections.logisticsAdmin).toBe("OVERRIDE LOGISTICS");
|
||||
expect(entry.sections.codeOfConduct).toBe("OVERRIDE COC");
|
||||
});
|
||||
|
||||
it("emits a methodSelections entry per selected group", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
selectedMembershipMethodIds: ["open-access"],
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
selectedConflictManagementIds: ["peer-mediation"],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const ms = r.document.methodSelections as
|
||||
| Record<string, Array<unknown>>
|
||||
| undefined;
|
||||
expect(Object.keys(ms ?? {}).sort()).toEqual([
|
||||
"communication",
|
||||
"conflictManagement",
|
||||
"decisionApproaches",
|
||||
"membership",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDocumentSectionsForDisplay", () => {
|
||||
it("returns empty for non-object", () => {
|
||||
expect(parseDocumentSectionsForDisplay(null)).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user