Final review edit modals created

This commit is contained in:
adilallo
2026-04-20 17:57:17 -06:00
parent c08cd62872
commit a22d53e860
27 changed files with 2410 additions and 620 deletions
+22 -2
View File
@@ -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>
+51
View File
@@ -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