"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 CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import { communicationPresetFor, conflictManagementPresetFor, coreValuePresetFor, decisionApproachPresetFor, membershipPresetFor, } from "../../../../lib/create/finalReviewChipPresets"; import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId"; import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; 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; customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[]; } | { groupKey: "membership"; overrideKey: string; value: MembershipMethodDetailEntry; customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[]; } | { groupKey: "decisionApproaches"; overrideKey: string; value: DecisionApproachDetailEntry; customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[]; } | { groupKey: "conflictManagement"; overrideKey: string; value: ConflictManagementDetailEntry; customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[]; }; 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(null); const [fieldBlocksDraft, setFieldBlocksDraft] = useState< CustomMethodCardFieldBlock[] | 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(""); const initialFieldBlocksSnapshotRef = useRef(""); const seededTargetRef = useRef(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); if ( target.groupKey !== "coreValues" && isCustomMethodCardId(target.overrideKey, state.customMethodCardMetaById) ) { const blocks = state.customMethodCardFieldBlocksById?.[target.overrideKey] ?? []; setFieldBlocksDraft(blocks); initialFieldBlocksSnapshotRef.current = JSON.stringify(blocks); } else { setFieldBlocksDraft(null); initialFieldBlocksSnapshotRef.current = ""; } seededTargetRef.current = targetKey; }, [isOpen, target, state]); useEffect(() => { if (!isOpen) seededTargetRef.current = null; }, [isOpen]); const isDirty = useMemo(() => { if (!draft || !target) return false; const valueDirty = JSON.stringify(draft.value) !== initialSnapshotRef.current; const customMethod = target.groupKey !== "coreValues" && isCustomMethodCardId(target.overrideKey, state.customMethodCardMetaById); const blocksDirty = customMethod && fieldBlocksDraft !== null && JSON.stringify(fieldBlocksDraft) !== initialFieldBlocksSnapshotRef.current; return valueDirty || Boolean(blocksDirty); }, [draft, target, state.customMethodCardMetaById, fieldBlocksDraft]); const handleSave = () => { if (!target || !draft || !isDirty) return; const { overrideKey } = target; const customBlocks = draft.groupKey !== "coreValues" && isCustomMethodCardId(overrideKey, state.customMethodCardMetaById) && fieldBlocksDraft !== null ? fieldBlocksDraft : undefined; switch (draft.groupKey) { case "coreValues": onSave({ groupKey: "coreValues", overrideKey, value: draft.value, }); break; case "communication": onSave({ groupKey: "communication", overrideKey, value: draft.value, ...(customBlocks !== undefined ? { customMethodCardFieldBlocks: customBlocks } : {}), }); break; case "membership": onSave({ groupKey: "membership", overrideKey, value: draft.value, ...(customBlocks !== undefined ? { customMethodCardFieldBlocks: customBlocks } : {}), }); break; case "decisionApproaches": onSave({ groupKey: "decisionApproaches", overrideKey, value: draft.value, ...(customBlocks !== undefined ? { customMethodCardFieldBlocks: customBlocks } : {}), }); break; case "conflictManagement": onSave({ groupKey: "conflictManagement", overrideKey, value: draft.value, ...(customBlocks !== undefined ? { customMethodCardFieldBlocks: customBlocks } : {}), }); break; } onClose(); }; const subtitle = useMemo(() => { if (!target) return ""; return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm }, state.customMethodCardMetaById); }, [target, tCv, tComm, tMem, tDa, tCm, state.customMethodCardMetaById]); return ( } showBackButton={false} showNextButton nextButtonText={tModal("saveButton")} nextButtonDisabled={!isDirty} onNext={handleSave} ariaLabel={target?.chipLabel || "Edit chip details"} >
{draft?.groupKey === "coreValues" && ( setDraft({ groupKey: "coreValues", value })} /> )} {draft?.groupKey === "communication" && (target && isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( ) : ( setDraft({ groupKey: "communication", value }) } /> ))} {draft?.groupKey === "membership" && (target && isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( ) : ( setDraft({ groupKey: "membership", value })} /> ))} {draft?.groupKey === "decisionApproaches" && (target && isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( ) : ( setDraft({ groupKey: "decisionApproaches", value }) } /> ))} {draft?.groupKey === "conflictManagement" && (target && isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( ) : ( setDraft({ groupKey: "conflictManagement", value }) } /> ))}
); } // ---------- 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["create"]["customRule"]["coreValues"]; tComm: ReturnType["create"]["customRule"]["communication"]; tMem: ReturnType["create"]["customRule"]["membership"]; tDa: ReturnType< typeof useMessages >["create"]["customRule"]["decisionApproaches"]; tCm: ReturnType< typeof useMessages >["create"]["customRule"]["conflictManagement"]; }; function subtitleForTarget( target: FinalReviewChipEditTarget, msgs: SubtitleMessages, customMeta?: CreateFlowState["customMethodCardMetaById"], ): string { switch (target.groupKey) { case "coreValues": return msgs.tCv.detailModal.subtitle; case "communication": { const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim(); if (fromCustom) return fromCustom; return findMethodSupportText(msgs.tComm.methods, target.overrideKey); } case "membership": { const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim(); if (fromCustom) return fromCustom; return findMethodSupportText(msgs.tMem.methods, target.overrideKey); } case "decisionApproaches": { const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim(); if (fromCustom) return fromCustom; return findMethodSupportText(msgs.tDa.methods, target.overrideKey); } case "conflictManagement": { const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim(); if (fromCustom) return fromCustom; 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 ""; }