diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index bc36822..642198f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -52,12 +52,12 @@ import { } from "./utils/anonymousDraftStorage"; import { createFlowStateFromPublishedRule, - isPublishedRuleSelectionMissing, + isPublishedRuleHydratePatchIncomplete, methodSectionsPinsFromPublishedHydratePatch, } from "../../../lib/create/publishedDocumentToCreateFlowState"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -import { deleteServerDraft } from "../../../lib/create/api"; +import { runCompletedStepExit } from "./utils/runCompletedStepExit"; import messages from "../../../messages/en/index"; import { CREATE_FLOW_FOOTER_BUTTON_CLASS, @@ -252,12 +252,11 @@ function CreateFlowLayoutContent({ // For signed-in users we also DELETE the server draft so a future visit to // /create starts fresh instead of rehydrating yesterday's work. if (currentStep === "completed") { - clearState(); - clearAnonymousCreateFlowStorage(); - if (sessionUser) { - void deleteServerDraft(); - } - router.push(CREATE_ROUTES.root); + runCompletedStepExit({ + clearState, + clearAnonymousCreateFlowStorage, + router, + }); return; } @@ -335,7 +334,7 @@ function CreateFlowLayoutContent({ titleOk && editingId === last.id && sectionsClear && - !isPublishedRuleSelectionMissing(state, patch) + !isPublishedRuleHydratePatchIncomplete(state, patch) ) { if (needsPinMerge) { updateState({ @@ -362,6 +361,7 @@ function CreateFlowLayoutContent({ state.title, state.methodSectionsPinCommitted, state.sections?.length, + state.customMethodCardMetaById, ]); useEffect(() => { diff --git a/app/(app)/create/components/ApplicableScopeField.tsx b/app/(app)/create/components/ApplicableScopeField.tsx index e31ef3e..e9a666b 100644 --- a/app/(app)/create/components/ApplicableScopeField.tsx +++ b/app/(app)/create/components/ApplicableScopeField.tsx @@ -35,6 +35,8 @@ export interface ApplicableScopeFieldProps { * Optional placeholder for the inline input. Defaults to `addLabel`. */ inputPlaceholder?: string; + /** When true, scope chips and add affordance are non-interactive. */ + readOnly?: boolean; className?: string; } @@ -46,6 +48,7 @@ function ApplicableScopeFieldComponent({ onToggleScope, onAddScope, inputPlaceholder, + readOnly = false, className = "", }: ApplicableScopeFieldProps) { const [draft, setDraft] = useState(""); @@ -78,13 +81,13 @@ function ApplicableScopeFieldComponent({ state={isSelected ? "selected" : "disabled"} palette="default" size="s" - disabled={false} - onClick={() => onToggleScope(scope)} + disabled={readOnly} + onClick={() => !readOnly && onToggleScope(scope)} ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`} /> ); })} - {isAdding ? ( + {readOnly ? null : isAdding ? ( ; @@ -52,7 +54,9 @@ function CustomMethodCardUploadBlockRow({ patch: (_next: CustomMethodCardFieldBlock[]) => void; uploadFileInputAriaLabel: string; uploadHint: string; - clearFileLabel: string; + clearPendingUploadAriaLabel: string; + clearPendingUploadTooltip: string; + uploadPreviewImageAlt: string; noFileChosen: string; }) { const uploadInputRef = useRef(null); @@ -60,8 +64,17 @@ function CustomMethodCardUploadBlockRow({ const [busy, setBusy] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const displayName = block.fileName?.trim() ? block.fileName : noFileChosen; - const hasAsset = Boolean(block.assetUrl?.trim()); - const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen; + const assetUrlTrimmed = block.assetUrl?.trim() ?? ""; + const hasAsset = assetUrlTrimmed.length > 0; + + const clearUpload = () => + patch( + mapBlockById(blocks, block.id, (b) => + b.kind === "upload" + ? { ...b, fileName: undefined, assetUrl: undefined } + : b, + ), + ); return (
@@ -76,14 +89,6 @@ function CustomMethodCardUploadBlockRow({ {displayName}

) : null} - {hasAsset ? ( - // eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL - {previewAlt} - ) : null} - { - if (!busy) uploadInputRef.current?.click(); - }} - /> + {hasAsset ? ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */} + {uploadPreviewImageAlt} +
+ ) : ( + { + if (!busy) uploadInputRef.current?.click(); + }} + /> + )} {errorMessage ? (

) : null} - {block.fileName?.trim() || block.assetUrl?.trim() ? ( - - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "upload" - ? { ...b, fileName: undefined, assetUrl: undefined } - : b, - ), - ) - } - > - {clearFileLabel} - - ) : null}

); } @@ -297,7 +315,13 @@ function CustomMethodCardFieldBlocksSummaryComponent({ patch={patch} uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel} uploadHint={fm.upload.uploadHint} - clearFileLabel={em.clearFileLabel} + clearPendingUploadAriaLabel={ + fm.upload.clearPendingUploadAriaLabel + } + clearPendingUploadTooltip={ + fm.upload.clearPendingUploadTooltip + } + uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt} noFileChosen={noFileChosen} /> )} diff --git a/app/(app)/create/components/CustomMethodCardModalBody.tsx b/app/(app)/create/components/CustomMethodCardModalBody.tsx index 1974a37..e601371 100644 --- a/app/(app)/create/components/CustomMethodCardModalBody.tsx +++ b/app/(app)/create/components/CustomMethodCardModalBody.tsx @@ -1,5 +1,7 @@ "use client"; +import ContentLockup from "../../../components/type/ContentLockup"; +import { useMessages } from "../../../contexts/MessagesContext"; import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; import type { CreateFlowState } from "../types"; import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary"; @@ -12,12 +14,22 @@ export default function CustomMethodCardModalBody({ /** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */ blocksOverride, onFieldBlocksChange, + policyMeta, + /** + * When false, omit {@link ContentLockup} for title/description (Customize mode: + * {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows. + * @default true + */ + showPolicyContentLockupWhenNoBlocks = true, }: { cardId: string; blocksById: CreateFlowState["customMethodCardFieldBlocksById"]; blocksOverride?: CustomMethodCardFieldBlock[] | null; onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void; + policyMeta?: { label: string; supportText: string }; + showPolicyContentLockupWhenNoBlocks?: boolean; }) { + const m = useMessages(); const blocks = blocksOverride ?? blocksById?.[cardId]; if (blocks && blocks.length > 0) { return ( @@ -27,5 +39,30 @@ export default function CustomMethodCardModalBody({ /> ); } + + const label = policyMeta?.label?.trim() ?? ""; + const support = policyMeta?.supportText?.trim() ?? ""; + if (label.length > 0 || support.length > 0) { + const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal + .noCustomFieldsYet; + return ( +
+ {showPolicyContentLockupWhenNoBlocks ? ( + 0 ? label : undefined} + description={support.length > 0 ? support : undefined} + variant="modal" + alignment="left" + /> + ) : null} + {noFieldsHint.trim().length > 0 ? ( +

+ {noFieldsHint} +

+ ) : null} +
+ ); + } + return ; } diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx index a899b3f..fabdee3 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -8,6 +8,7 @@ import { import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view"; import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types"; @@ -21,6 +22,7 @@ const CustomMethodCardWizardContainer = memo( const t = useTranslation("common"); const tUpload = useTranslation("create.upload"); const w = m.create.customRule.customMethodCardWizard; + const menuCopy = m.create.customRule.modalKebabMenu; const copy = useMemo( () => ({ @@ -228,6 +230,8 @@ const CustomMethodCardWizardContainer = memo( dismiss(); }, [dismiss, fieldTypeModal]); + const kebabMenuItems = useMemo(() => [], []); + const handleBack = useCallback(() => { if (fieldTypeModal) { setFieldTypeModal(null); @@ -416,6 +420,9 @@ const CustomMethodCardWizardContainer = memo( stepper={!fieldTypeModal} draftFieldBlocks={draftFieldBlocks} onDraftFieldBlocksReorder={setDraftFieldBlocks} + kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel} + kebabMenuAriaLabel={menuCopy.menuAriaLabel} + kebabMenuItems={kebabMenuItems} /> ); }, diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts index 65b33c8..ee618dc 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; export interface CustomMethodCardWizardFieldBodiesCopy { @@ -141,4 +142,7 @@ export interface CustomMethodCardWizardViewProps { onBack: () => void; onNext: () => void; stepper: boolean; + kebabMoreOptionsAriaLabel: string; + kebabMenuAriaLabel: string; + kebabMenuItems: ModalHeaderMenuItem[]; } diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx index 1c6fb51..237a3b7 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx @@ -35,6 +35,9 @@ function CustomMethodCardWizardViewComponent({ stepper, draftFieldBlocks, onDraftFieldBlocksReorder, + kebabMoreOptionsAriaLabel, + kebabMenuAriaLabel, + kebabMenuItems, }: CustomMethodCardWizardViewProps) { return ( {fieldTypeModal ? ( 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; + replaceState: (_updater: (prev: CreateFlowState) => CreateFlowState) => void; + onInteract?: () => void; + /** After core-value **Duplicate**, re-point the open modal at the new chip id. */ + onEditTargetChange?: (_next: FinalReviewChipEditTarget) => 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 } @@ -117,185 +131,948 @@ type Draft = | { groupKey: "decisionApproaches"; value: DecisionApproachDetailEntry } | { groupKey: "conflictManagement"; value: ConflictManagementDetailEntry }; +type MethodDetailDraft = + | CommunicationMethodDetailEntry + | MembershipMethodDetailEntry + | DecisionApproachDetailEntry + | ConflictManagementDetailEntry; + +function methodDetailDraftForCustomizeSession( + draft: Draft | null, +): MethodDetailDraft | null { + if (!draft || draft.groupKey === "coreValues") return null; + return draft.value; +} + +function isMethodFacetGroup( + k: TemplateFacetGroupKey, +): k is MethodFacetGroupKey { + return k !== "coreValues"; +} + export function FinalReviewChipEditModal({ isOpen, onClose, target, state, onSave, + replaceState, + onInteract, + onEditTargetChange, }: 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 cr = m.create.customRule; + const tCv = cr.coreValues; + const tComm = cr.communication; + const tMem = cr.membership; + const tDa = cr.decisionApproaches; + const tCm = cr.conflictManagement; + const modalKebabMenu = cr.modalKebabMenu; const tModal = useTranslation( "create.reviewAndComplete.finalReview.chipEditModal", ); const [draft, setDraft] = useState(null); - const [fieldBlocksDraft, setFieldBlocksDraft] = useState< + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [draftFieldBlocks, setDraftFieldBlocks] = 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 [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); + + const initialSnapshotRef = useRef(""); const seededTargetRef = useRef(null); + const customizeSnapshotRef = useRef< + | MethodCardCustomizeSnapshot< + | CommunicationMethodDetailEntry + | MembershipMethodDetailEntry + | DecisionApproachDetailEntry + | ConflictManagementDetailEntry + > + | null + >(null); + const coreCustomizeSnapshotRef = + useRef | null>(null); + const pendingEphemeralCoreDuplicateRef = useRef(null); + const methodById = useMemo(() => { + if (!target || !isMethodFacetGroup(target.groupKey)) { + return new Map(); + } + const facet = CUSTOM_RULE_FACET_BY_GROUP.get(target.groupKey)!; + const selectedIds = facet.selectionIds(state); + switch (target.groupKey) { + case "communication": + return new Map( + mergePresetMethodsWithCustom( + tComm.methods, + selectedIds, + state.customMethodCardMetaById, + ).map((row) => [row.id, row]), + ); + case "membership": + return new Map( + mergePresetMethodsWithCustom( + tMem.methods, + selectedIds, + state.customMethodCardMetaById, + ).map((row) => [row.id, row]), + ); + case "decisionApproaches": + return new Map( + mergePresetMethodsWithCustom( + tDa.methods, + selectedIds, + state.customMethodCardMetaById, + ).map((row) => [row.id, row]), + ); + case "conflictManagement": + return new Map( + mergePresetMethodsWithCustom( + tCm.methods, + selectedIds, + state.customMethodCardMetaById, + ).map((row) => [row.id, row]), + ); + } + }, [ + target, + state.customMethodCardMetaById, + state.selectedCommunicationMethodIds, + state.selectedMembershipMethodIds, + state.selectedDecisionApproachIds, + state.selectedConflictManagementIds, + tComm.methods, + tMem.methods, + tDa.methods, + tCm.methods, + ]); + + const selectionIdsForTarget = useMemo(() => { + if (!target || !isMethodFacetGroup(target.groupKey)) return []; + return [...CUSTOM_RULE_FACET_BY_GROUP.get(target.groupKey)!.selectionIds(state)]; + }, [ + target, + state.selectedCommunicationMethodIds, + state.selectedMembershipMethodIds, + state.selectedDecisionApproachIds, + state.selectedConflictManagementIds, + ]); + + const isChipInSelection = + target && isMethodFacetGroup(target.groupKey) + ? selectionIdsForTarget.includes(target.overrideKey) + : false; + + const fieldsLocked = + target !== null && + (target.groupKey === "coreValues" || isMethodFacetGroup(target.groupKey)) && + !modalEditUnlocked; + + const showMethodModalPrimary = !isChipInSelection || modalEditUnlocked; + const showCoreModalPrimary = modalEditUnlocked; useEffect(() => { if (!isOpen || !target) return; - const targetKey = `${target.groupKey}:${target.overrideKey}`; - if (seededTargetRef.current === targetKey) return; + if (modalEditUnlocked) { + return; + } + const sig = facetSeedSignature(target, state); + const targetKey = `${target.groupKey}:${target.overrideKey}:${sig}`; + 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 = ""; + if (target.groupKey === "coreValues") { + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + coreCustomizeSnapshotRef.current = null; + } + if (isMethodFacetGroup(target.groupKey)) { + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + customizeSnapshotRef.current = null; } seededTargetRef.current = targetKey; - }, [isOpen, target, state]); + }, [isOpen, target, state, modalEditUnlocked]); 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 coreCustomizeSaveDisabled = useMemo(() => { + if (!modalEditUnlocked) return false; + const snap = coreCustomizeSnapshotRef.current; + if (!snap || !draft || draft.groupKey !== "coreValues") return true; + return !isMethodCardCustomizeSessionDirty( + snap, + draft.value, + null, + customizeHeaderDraft, + ); + }, [customizeHeaderDraft, draft, modalEditUnlocked]); - const handleSave = () => { - if (!target || !draft || !isDirty) return; - const { overrideKey } = target; - const customBlocks = - draft.groupKey !== "coreValues" && - isCustomMethodCardId(overrideKey, state.customMethodCardMetaById) && - fieldBlocksDraft !== null - ? fieldBlocksDraft - : undefined; + const methodCustomizeSaveDisabled = useMemo(() => { + if (!modalEditUnlocked) return false; + const snap = customizeSnapshotRef.current; + if (!snap) return true; + return !isMethodCardCustomizeSessionDirty( + snap, + methodDetailDraftForCustomizeSession(draft), + draftFieldBlocks, + customizeHeaderDraft, + ); + }, [ + customizeHeaderDraft, + draft, + draftFieldBlocks, + modalEditUnlocked, + ]); - switch (draft.groupKey) { - case "coreValues": - onSave({ - groupKey: "coreValues", - overrideKey, - value: draft.value, + const finalizeModalClose = useCallback(() => { + customizeSnapshotRef.current = null; + coreCustomizeSnapshotRef.current = null; + pendingEphemeralCoreDuplicateRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + onClose(); + }, [onClose]); + + const handleModalClose = useCallback(() => { + if ( + target && + target.groupKey === "coreValues" && + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft?.groupKey === "coreValues" ? draft.value : null, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + if ( + target && + isMethodFacetGroup(target.groupKey) && + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + methodDetailDraftForCustomizeSession(draft), + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + const ep = pendingEphemeralCoreDuplicateRef.current; + if (ep) { + replaceState((prev) => ({ + ...prev, + ...removeCoreValueChipFromDraft(prev, ep), + })); + } + finalizeModalClose(); + }, [ + customizeHeaderDraft, + draft, + draftFieldBlocks, + finalizeModalClose, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + replaceState, + target, + ]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked || !target) { + return; + } + if (target.groupKey === "coreValues") { + const snap = coreCustomizeSnapshotRef.current; + if (!snap) { + coreCustomizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + return; + } + if ( + draft?.groupKey === "coreValues" && + isMethodCardCustomizeSessionDirty( + snap, + draft.value, + null, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setDraft({ + groupKey: "coreValues", + value: structuredClone(snap.pendingDraft), + }); + coreCustomizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + return; + } + if (!isMethodFacetGroup(target.groupKey)) { + return; + } + const snap = customizeSnapshotRef.current; + if (!snap) { + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if ( + isMethodCardCustomizeSessionDirty( + snap, + methodDetailDraftForCustomizeSession(draft), + draftFieldBlocks, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setPendingDraftFromSnapshot(snap); + setDraftFieldBlocks(null); + setModalEditUnlocked(false); + customizeSnapshotRef.current = null; + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + target, + ]); + + function setPendingDraftFromSnapshot( + snap: MethodCardCustomizeSnapshot< + | CommunicationMethodDetailEntry + | MembershipMethodDetailEntry + | DecisionApproachDetailEntry + | ConflictManagementDetailEntry + >, + ) { + const v = structuredClone(snap.pendingDraft); + if (!target || !isMethodFacetGroup(target.groupKey)) return; + switch (target.groupKey) { + case "communication": + setDraft({ groupKey: "communication", value: v as CommunicationMethodDetailEntry }); + break; + case "membership": + setDraft({ groupKey: "membership", value: v as MembershipMethodDetailEntry }); + break; + case "decisionApproaches": + setDraft({ + groupKey: "decisionApproaches", + value: v as DecisionApproachDetailEntry, }); break; + case "conflictManagement": + setDraft({ + groupKey: "conflictManagement", + value: v as ConflictManagementDetailEntry, + }); + break; + default: { + const _e: never = target.groupKey; + void _e; + } + } + } + + const handleCustomize = useCallback(() => { + onInteract?.(); + if (target?.groupKey === "coreValues") { + if (!draft || draft.groupKey !== "coreValues") return; + const headerDraft: MethodCardHeaderDraft = { + title: target.chipLabel, + description: "", + }; + coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft.value, + null, + headerDraft, + ); + setCustomizeHeaderDraft(headerDraft); + setModalEditUnlocked(true); + return; + } + const pending = pendingDraftForCustomize(draft, target); + if (!pending) return; + const { groupKey, pendingValue } = pending; + const pendingCardId = target!.overrideKey; + const initialFieldBlocks = isCustomMethodCardId( + pendingCardId, + state.customMethodCardMetaById, + ) + ? structuredClone( + state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [], + ) + : null; + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const confirm = confirmCopyForMethodGroup(groupKey, { + tComm, + tMem, + tDa, + tCm, + }); + + const headerDraft: MethodCardHeaderDraft = { + title: + meta?.label ?? + method?.label ?? + target!.chipLabel ?? + confirm.title, + description: + meta?.supportText ?? + method?.supportText ?? + confirm.description, + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingValue, + initialFieldBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialFieldBlocks); + setModalEditUnlocked(true); + }, [ + draft, + methodById, + onInteract, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + target, + tComm, + tMem, + tDa, + tCm, + ]); + + const handleRemoveSelectedFromModal = useCallback(() => { + if (!target || !isMethodFacetGroup(target.groupKey)) { + return; + } + const methodGroupKey = target.groupKey; + if (!selectionIdsForTarget.includes(target.overrideKey)) { + return; + } + onInteract?.(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + methodDetailDraftForCustomizeSession(draft), + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + replaceState((prev) => ({ + ...prev, + ...removeMethodCardFromFacetSelection( + prev, + methodGroupKey, + target.overrideKey, + ), + })); + finalizeModalClose(); + }, [ + customizeHeaderDraft, + draft, + draftFieldBlocks, + finalizeModalClose, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + onInteract, + replaceState, + selectionIdsForTarget, + target, + ]); + + const handleRemoveCoreValueFromModal = useCallback(() => { + if (!target || target.groupKey !== "coreValues") { + return; + } + onInteract?.(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft?.groupKey === "coreValues" ? draft.value : null, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + coreCustomizeSnapshotRef.current = null; + customizeSnapshotRef.current = null; + replaceState((prev) => ({ + ...prev, + ...removeCoreValueChipFromDraft(prev, target.overrideKey), + })); + finalizeModalClose(); + }, [ + customizeHeaderDraft, + draft, + finalizeModalClose, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + onInteract, + replaceState, + target, + ]); + + const handleDuplicateCoreValue = useCallback(() => { + if ( + !target || + target.groupKey !== "coreValues" || + draft?.groupKey !== "coreValues" + ) { + return; + } + if ((state.editingPublishedRuleId?.trim() ?? "") !== "") { + return; + } + if ((state.selectedCoreValueIds ?? []).length >= 5) { + return; + } + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft.value, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + onInteract?.(); + const priorEphemeral = pendingEphemeralCoreDuplicateRef.current; + let outcome: ReturnType< + typeof duplicateCoreValueChipInDraft + > | null = null; + replaceState((prev) => { + const base = + priorEphemeral != null + ? { ...prev, ...removeCoreValueChipFromDraft(prev, priorEphemeral) } + : prev; + const res = duplicateCoreValueChipInDraft( + base, + target.overrideKey, + modalKebabMenu.duplicateTitleSuffix, + ); + if (!res) { + return prev; + } + outcome = res; + return { ...base, ...res.patch }; + }); + if (!outcome) { + return; + } + customizeSnapshotRef.current = null; + coreCustomizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + pendingEphemeralCoreDuplicateRef.current = outcome.newId; + seededTargetRef.current = null; + setDraft({ + groupKey: "coreValues", + value: structuredClone(draft.value), + }); + onEditTargetChange?.({ + overrideKey: outcome.newId, + groupKey: "coreValues", + chipLabel: outcome.newLabel, + }); + }, [ + customizeHeaderDraft, + draft, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + modalKebabMenu.duplicateTitleSuffix, + onEditTargetChange, + onInteract, + replaceState, + state.editingPublishedRuleId, + state.selectedCoreValueIds, + target, + ]); + + const kebabMenuItems = useMemo(() => { + if (!target) return []; + if (target.groupKey === "coreValues") { + return buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + (state.editingPublishedRuleId?.trim() ?? "") !== "" || + (state.selectedCoreValueIds ?? []).length >= 5 + ? undefined + : handleDuplicateCoreValue, + showRemove: true, + onRemove: handleRemoveCoreValueFromModal, + }); + } + if (!isMethodFacetGroup(target.groupKey)) return []; + return buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + showRemove: isChipInSelection, + onRemove: handleRemoveSelectedFromModal, + }); + }, [ + handleCustomize, + handleDuplicateCoreValue, + handleRemoveCoreValueFromModal, + handleRemoveSelectedFromModal, + isChipInSelection, + modalEditUnlocked, + modalKebabMenu, + state.editingPublishedRuleId, + state.selectedCoreValueIds, + target, + ]); + + const subtitle = useMemo(() => { + if (!target) return ""; + return subtitleForTarget( + target, + { tCv, tComm, tMem, tDa, tCm }, + state.customMethodCardMetaById, + ); + }, [target, tCv, tComm, tMem, tDa, tCm, state.customMethodCardMetaById]); + + const handleCoreSave = useCallback(() => { + if (!target || !draft || draft.groupKey !== "coreValues") { + return; + } + if (!modalEditUnlocked || !customizeHeaderDraft) { + return; + } + if (coreCustomizeSaveDisabled) { + return; + } + const labelTrim = customizeHeaderDraft.title.trim(); + onInteract?.(); + onSave({ + groupKey: "coreValues", + overrideKey: target.overrideKey, + value: structuredClone(draft.value), + ...(labelTrim.length > 0 ? { chipLabel: labelTrim } : {}), + }); + coreCustomizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + initialSnapshotRef.current = JSON.stringify(draft.value); + pendingEphemeralCoreDuplicateRef.current = null; + onEditTargetChange?.({ + overrideKey: target.overrideKey, + groupKey: "coreValues", + chipLabel: labelTrim.length > 0 ? labelTrim : target.chipLabel, + }); + }, [ + coreCustomizeSaveDisabled, + customizeHeaderDraft, + draft, + modalEditUnlocked, + onEditTargetChange, + onInteract, + onSave, + target, + ]); + + const handleMethodPrimary = useCallback(() => { + if (!target || !draft || !isMethodFacetGroup(target.groupKey)) return; + const facet = CUSTOM_RULE_FACET_BY_GROUP.get(target.groupKey)!; + const pendingId = target.overrideKey; + const sel = [...facet.selectionIds(state)]; + + if (!modalEditUnlocked) { + if (!sel.includes(pendingId)) { + onInteract?.(); + replaceState((prev) => ({ + ...prev, + [facet.selectedIdsStateKey]: moveFacetSelectionIdToFront( + [...facet.selectionIds(prev)], + pendingId, + ), + })); + onClose(); + } + return; + } + + if (!customizeHeaderDraft) return; + if (!isMethodFacetGroup(draft.groupKey)) return; + onInteract?.(); + const header = customizeHeaderDraft; + const metaSave = { + label: header.title, + supportText: header.description, + }; + const useWizard = usesWizardFieldBlocksModalBody({ + methodId: pendingId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + }); + + const blocksPayload = useWizard + ? structuredClone(draftFieldBlocks ?? []) + : undefined; + + switch (draft.groupKey) { case "communication": onSave({ groupKey: "communication", - overrideKey, + overrideKey: pendingId, value: draft.value, - ...(customBlocks !== undefined - ? { customMethodCardFieldBlocks: customBlocks } + methodCardMeta: metaSave, + ...(blocksPayload !== undefined + ? { customMethodCardFieldBlocks: blocksPayload } : {}), }); break; case "membership": onSave({ groupKey: "membership", - overrideKey, + overrideKey: pendingId, value: draft.value, - ...(customBlocks !== undefined - ? { customMethodCardFieldBlocks: customBlocks } + methodCardMeta: metaSave, + ...(blocksPayload !== undefined + ? { customMethodCardFieldBlocks: blocksPayload } : {}), }); break; case "decisionApproaches": onSave({ groupKey: "decisionApproaches", - overrideKey, + overrideKey: pendingId, value: draft.value, - ...(customBlocks !== undefined - ? { customMethodCardFieldBlocks: customBlocks } + methodCardMeta: metaSave, + ...(blocksPayload !== undefined + ? { customMethodCardFieldBlocks: blocksPayload } : {}), }); break; case "conflictManagement": onSave({ groupKey: "conflictManagement", - overrideKey, + overrideKey: pendingId, value: draft.value, - ...(customBlocks !== undefined - ? { customMethodCardFieldBlocks: customBlocks } + methodCardMeta: metaSave, + ...(blocksPayload !== undefined + ? { customMethodCardFieldBlocks: blocksPayload } : {}), }); break; } - onClose(); - }; + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draft, + draftFieldBlocks, + modalEditUnlocked, + onClose, + onInteract, + onSave, + replaceState, + state, + target, + ]); - const subtitle = useMemo(() => { - if (!target) return ""; - return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm }, state.customMethodCardMetaById); - }, [target, tCv, tComm, tMem, tDa, tCm, state.customMethodCardMetaById]); + const handleNext = useCallback(() => { + if (!target || !draft) return; + if (target.groupKey === "coreValues") { + handleCoreSave(); + } else { + handleMethodPrimary(); + } + }, [draft, handleCoreSave, handleMethodPrimary, target]); + + const nextButtonText = useMemo(() => { + if (!target) return tModal("saveButton"); + if (target.groupKey === "coreValues" && modalEditUnlocked) { + return modalKebabMenu.saveEdits; + } + if (target.groupKey === "coreValues") { + return tModal("saveButton"); + } + if (modalEditUnlocked) return modalKebabMenu.saveEdits; + if (!isChipInSelection) return addPrimaryLabelForMethodFacet(target.groupKey, cr); + return tModal("saveButton"); + }, [ + cr, + isChipInSelection, + modalEditUnlocked, + modalKebabMenu.saveEdits, + target, + tModal, + ]); + + const headerContent = useMemo(() => { + if ( + target && + target.groupKey === "coreValues" && + modalEditUnlocked && + customizeHeaderDraft + ) { + return ( + + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={() => {}} + showDescription={false} + /> + ); + } + if ( + target && + isMethodFacetGroup(target.groupKey) && + modalEditUnlocked && + customizeHeaderDraft + ) { + return ( + + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={(description) => + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, description } : null, + ) + } + /> + ); + } + if (!target) return undefined; + return ( +
+ +
+ ); + }, [ + customizeHeaderDraft, + modalEditUnlocked, + modalKebabMenu.customizePolicyDescriptionLabel, + modalKebabMenu.customizePolicyTitleLabel, + subtitle, + target, + tCv.detailModal.customizeValueNameLabel, + ]); + + const showNext = + target?.groupKey === "coreValues" + ? showCoreModalPrimary + : showMethodModalPrimary; return ( - - - + headerContent={headerContent} + showBackButton={ + target != null && + modalEditUnlocked && + (target.groupKey === "coreValues" || isMethodFacetGroup(target.groupKey)) + } + showNextButton={showNext} + onBack={handleCancelCustomize} + onNext={handleNext} + backButtonText={modalKebabMenu.cancelCustomize} + nextButtonText={nextButtonText} + nextButtonDisabled={ + target?.groupKey === "coreValues" + ? modalEditUnlocked && coreCustomizeSaveDisabled + : modalEditUnlocked && methodCustomizeSaveDisabled + } + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={ + target && kebabMenuItems.length > 0 ? kebabMenuItems : undefined } - 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 && + (isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( setDraftFieldBlocks(next) + } /> ) : ( setDraft({ groupKey: "communication", value }) } + readOnly={fieldsLocked} /> ))} {draft?.groupKey === "membership" && - (target && - isCustomMethodCardId( + target && + (isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( setDraftFieldBlocks(next) + } /> ) : ( setDraft({ groupKey: "membership", value })} + onChange={(value) => + setDraft({ groupKey: "membership", value }) + } + readOnly={fieldsLocked} /> ))} {draft?.groupKey === "decisionApproaches" && - (target && - isCustomMethodCardId( + target && + (isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( setDraftFieldBlocks(next) + } /> ) : ( setDraft({ groupKey: "decisionApproaches", value }) } + readOnly={fieldsLocked} /> ))} {draft?.groupKey === "conflictManagement" && - (target && - isCustomMethodCardId( + target && + (isCustomMethodCardId( target.overrideKey, state.customMethodCardMetaById, ) ? ( setDraftFieldBlocks(next) + } /> ) : ( setDraft({ groupKey: "conflictManagement", value }) } + readOnly={fieldsLocked} /> ))}
@@ -370,6 +1189,105 @@ export function FinalReviewChipEditModal({ // ---------- helpers ------------------------------------------------------ +function facetSeedSignature( + target: FinalReviewChipEditTarget, + state: CreateFlowState, +): string { + const id = target.overrideKey; + switch (target.groupKey) { + case "coreValues": + return JSON.stringify({ + details: state.coreValueDetailsByChipId?.[id], + row: + state.coreValuesChipsSnapshot?.find((r) => r.id === id) ?? null, + }); + case "communication": + return JSON.stringify({ + meta: state.customMethodCardMetaById?.[id] ?? null, + details: state.communicationMethodDetailsById?.[id] ?? null, + blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, + }); + case "membership": + return JSON.stringify({ + meta: state.customMethodCardMetaById?.[id] ?? null, + details: state.membershipMethodDetailsById?.[id] ?? null, + blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, + }); + case "decisionApproaches": + return JSON.stringify({ + meta: state.customMethodCardMetaById?.[id] ?? null, + details: state.decisionApproachDetailsById?.[id] ?? null, + blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, + }); + case "conflictManagement": + return JSON.stringify({ + meta: state.customMethodCardMetaById?.[id] ?? null, + details: state.conflictManagementDetailsById?.[id] ?? null, + blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, + }); + default: { + const _e: never = target.groupKey; + return String(_e); + } + } +} + +function pendingDraftForCustomize( + draft: Draft | null, + target: FinalReviewChipEditTarget | null, +): { groupKey: MethodFacetGroupKey; pendingValue: MethodDetailDraft } | null { + if (!draft || !target || !isMethodFacetGroup(target.groupKey)) return null; + switch (draft.groupKey) { + case "coreValues": + return null; + case "communication": + return { groupKey: "communication", pendingValue: draft.value }; + case "membership": + return { groupKey: "membership", pendingValue: draft.value }; + case "decisionApproaches": + return { groupKey: "decisionApproaches", pendingValue: draft.value }; + case "conflictManagement": + return { groupKey: "conflictManagement", pendingValue: draft.value }; + } +} + +function confirmCopyForMethodGroup( + groupKey: MethodFacetGroupKey, + t: { + tComm: { confirmModal: { title: string; description: string } }; + tMem: { confirmModal: { title: string; description: string } }; + tDa: { confirmModal: { title: string; description: string } }; + tCm: { confirmModal: { title: string; description: string } }; + }, +) { + switch (groupKey) { + case "communication": + return t.tComm.confirmModal; + case "membership": + return t.tMem.confirmModal; + case "decisionApproaches": + return t.tDa.confirmModal; + case "conflictManagement": + return t.tCm.confirmModal; + } +} + +function addPrimaryLabelForMethodFacet( + groupKey: MethodFacetGroupKey, + cr: ReturnType["create"]["customRule"], +): string { + switch (groupKey) { + case "communication": + return cr.communication.addPlatform.nextButtonText; + case "membership": + return cr.membership.addPlatform.nextButtonText; + case "decisionApproaches": + return cr.decisionApproaches.addApproach.nextButtonText; + case "conflictManagement": + return cr.conflictManagement.addApproach.nextButtonText; + } +} + function seedDraftForTarget( target: FinalReviewChipEditTarget, state: CreateFlowState, diff --git a/app/(app)/create/components/MethodCardCustomizeModalHeader.tsx b/app/(app)/create/components/MethodCardCustomizeModalHeader.tsx new file mode 100644 index 0000000..97e11c7 --- /dev/null +++ b/app/(app)/create/components/MethodCardCustomizeModalHeader.tsx @@ -0,0 +1,52 @@ +"use client"; + +/** + * Editable policy title + description for method-card Create modals in Customize mode. + * View mode continues to use {@link ContentLockup} via the `Create` modal defaults. + */ + +import TextInput from "../../../components/controls/TextInput"; +import ModalTextAreaField from "./ModalTextAreaField"; + +export interface MethodCardCustomizeModalHeaderProps { + titleLabel: string; + descriptionLabel: string; + titleValue: string; + descriptionValue: string; + onTitleChange: (_value: string) => void; + onDescriptionChange: (_value: string) => void; + /** @default 3 */ + descriptionRows?: number; + /** When false, only the policy title row is rendered (core values rename). */ + showDescription?: boolean; +} + +export default function MethodCardCustomizeModalHeader({ + titleLabel, + descriptionLabel, + titleValue, + descriptionValue, + onTitleChange, + onDescriptionChange, + descriptionRows = 3, + showDescription = true, +}: MethodCardCustomizeModalHeaderProps) { + return ( +
+ onTitleChange(e.target.value)} + inputSize="medium" + /> + {showDescription ? ( + + ) : null} +
+ ); +} diff --git a/app/(app)/create/components/customRuleModalKebabMenu.ts b/app/(app)/create/components/customRuleModalKebabMenu.ts new file mode 100644 index 0000000..95a64a0 --- /dev/null +++ b/app/(app)/create/components/customRuleModalKebabMenu.ts @@ -0,0 +1,51 @@ +import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types"; + +export interface CustomRuleModalKebabMenuCopy { + items: { + customize: string; + duplicate: string; + remove: string; + }; + saveEdits: string; +} + +export interface CustomRuleModalKebabHandlers { + showCustomize?: boolean; + onCustomize?: () => void; + onDuplicate?: () => void; + showRemove?: boolean; + onRemove?: () => void; +} + +export function buildCustomRuleModalKebabMenu( + copy: CustomRuleModalKebabMenuCopy, + handlers: CustomRuleModalKebabHandlers, +): ModalHeaderMenuItem[] { + const items: ModalHeaderMenuItem[] = []; + if (handlers.showCustomize && handlers.onCustomize) { + items.push({ + id: "customize", + label: copy.items.customize, + leadingIcon: "custom", + onClick: handlers.onCustomize, + }); + } + if (handlers.onDuplicate) { + items.push({ + id: "duplicate", + label: copy.items.duplicate, + leadingIcon: "content_copy", + onClick: handlers.onDuplicate, + }); + } + if (handlers.showRemove && handlers.onRemove) { + items.push({ + id: "remove", + label: copy.items.remove, + leadingIcon: "warning", + variant: "destructive", + onClick: handlers.onRemove, + }); + } + return items; +} diff --git a/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx index 221baa7..5a42ed2 100644 --- a/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/CommunicationMethodEditFields.tsx @@ -15,6 +15,8 @@ import type { CommunicationMethodDetailEntry } from "../../types"; export interface CommunicationMethodEditFieldsProps { value: CommunicationMethodDetailEntry; onChange: (_next: CommunicationMethodDetailEntry) => void; + /** When true, fields are not editable (view mode). */ + readOnly?: boolean; } const FIELDS: ReadonlyArray = [ @@ -26,6 +28,7 @@ const FIELDS: ReadonlyArray = [ function CommunicationMethodEditFieldsComponent({ value, onChange, + readOnly = false, }: CommunicationMethodEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.communication; @@ -49,6 +52,7 @@ function CommunicationMethodEditFieldsComponent({ rows={6} value={value[field]} onChange={(v) => patch(field, v)} + disabled={readOnly} /> ))} diff --git a/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx index ddb1d47..0f6a987 100644 --- a/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/ConflictManagementEditFields.tsx @@ -37,11 +37,13 @@ function conflictDetailWithScopeTextarea( export interface ConflictManagementEditFieldsProps { value: ConflictManagementDetailEntry; onChange: (_next: ConflictManagementDetailEntry) => void; + readOnly?: boolean; } function ConflictManagementEditFieldsComponent({ value, onChange, + readOnly = false, }: ConflictManagementEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.conflictManagement; @@ -62,6 +64,7 @@ function ConflictManagementEditFieldsComponent({ label={t.sectionHeadings.corePrinciple} value={value.corePrinciple} onChange={(v) => patch("corePrinciple", v)} + disabled={readOnly} /> onChange(conflictDetailWithScopeTextarea(value, v))} rows={4} + disabled={readOnly} /> patch("processProtocol", v)} + disabled={readOnly} /> patch("restorationFallbacks", v)} + disabled={readOnly} /> ); diff --git a/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx index e612028..defa96d 100644 --- a/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/CoreValueEditFields.tsx @@ -15,11 +15,14 @@ import type { CoreValueDetailEntry } from "../../types"; export interface CoreValueEditFieldsProps { value: CoreValueDetailEntry; onChange: (_next: CoreValueDetailEntry) => void; + /** View mode until the user taps **Customize**. */ + readOnly?: boolean; } function CoreValueEditFieldsComponent({ value, onChange, + readOnly = false, }: CoreValueEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.coreValues.detailModal; @@ -41,12 +44,14 @@ function CoreValueEditFieldsComponent({ value={value.meaning} onChange={(v) => patch("meaning", v)} rows={4} + disabled={readOnly} /> patch("signals", v)} rows={4} + disabled={readOnly} /> ); diff --git a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx index 08c3132..4bc7fdb 100644 --- a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx @@ -17,6 +17,7 @@ import type { DecisionApproachDetailEntry } from "../../types"; export interface DecisionApproachEditFieldsProps { value: DecisionApproachDetailEntry; onChange: (_next: DecisionApproachDetailEntry) => void; + readOnly?: boolean; } const CONSENSUS_LEVEL_MIN = 0; @@ -26,6 +27,7 @@ const CONSENSUS_LEVEL_STEP = 5; function DecisionApproachEditFieldsComponent({ value, onChange, + readOnly = false, }: DecisionApproachEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.decisionApproaches; @@ -46,12 +48,14 @@ function DecisionApproachEditFieldsComponent({ label={t.sectionHeadings.corePrinciple} value={value.corePrinciple} onChange={(v) => patch("corePrinciple", v)} + disabled={readOnly} /> patch( "selectedApplicableScope", @@ -68,6 +72,7 @@ function DecisionApproachEditFieldsComponent({ label={t.sectionHeadings.stepByStepInstructions} value={value.stepByStepInstructions} onChange={(v) => patch("stepByStepInstructions", v)} + disabled={readOnly} /> `${v}%`} decrementAriaLabel="Decrease consensus level" incrementAriaLabel="Increase consensus level" + disabled={readOnly} /> patch("objectionsDeadlocks", v)} + disabled={readOnly} /> ); diff --git a/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx index 8372756..aea2d55 100644 --- a/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/MembershipMethodEditFields.tsx @@ -15,6 +15,7 @@ import type { MembershipMethodDetailEntry } from "../../types"; export interface MembershipMethodEditFieldsProps { value: MembershipMethodDetailEntry; onChange: (_next: MembershipMethodDetailEntry) => void; + readOnly?: boolean; } const FIELDS: ReadonlyArray = [ @@ -26,6 +27,7 @@ const FIELDS: ReadonlyArray = [ function MembershipMethodEditFieldsComponent({ value, onChange, + readOnly = false, }: MembershipMethodEditFieldsProps) { const m = useMessages(); const t = m.create.customRule.membership; @@ -49,6 +51,7 @@ function MembershipMethodEditFieldsComponent({ rows={6} value={value[field]} onChange={(v) => patch(field, v)} + disabled={readOnly} /> ))} diff --git a/app/(app)/create/hooks/useCreateFlowExit.ts b/app/(app)/create/hooks/useCreateFlowExit.ts index 6c4b358..c24252a 100644 --- a/app/(app)/create/hooks/useCreateFlowExit.ts +++ b/app/(app)/create/hooks/useCreateFlowExit.ts @@ -3,11 +3,7 @@ import { useCallback } from "react"; import type { CreateFlowState, CreateFlowStep } from "../types"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; -import { - deleteServerDraft, - saveDraftToServer, - updatePublishedRule, -} from "../../../../lib/create/api"; +import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import messages from "../../../../messages/en/index"; @@ -79,7 +75,6 @@ export function useCreateFlowExit({ document, }); setDraftSaveBannerMessage?.(null); - void deleteServerDraft(); } else { setDraftSaveBannerMessage?.(updateResult.error); return; diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index a4ea05c..ee8bacf 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -10,12 +10,12 @@ * * Card click opens the Figma create modal (node `20246-15829`) with three * editable sections rendered by {@link CommunicationMethodEditFields}. The primary - * action is **Add Platform** for an unselected card or **Remove** when the card is - * already selected — remove clears `selectedCommunicationMethodIds` and - * `communicationMethodDetailsById` via {@link removeMethodCardFromFacetSelection}. + * action is **Add Platform** for an unselected card; a selected card in view mode has + * no footer primary — **Remove** is available from the kebab (same behavior as legacy + * footer remove via {@link removeMethodCardFromFacetSelection}). */ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; @@ -37,22 +37,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId"; +import { communicationMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId"; +import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody"; import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection"; +import { + cloneMethodCardBlocksForDuplicate, + cloneMethodCardDetailsForDuplicate, + duplicateMethodCardTitle, + forkMethodCardFacetMapsForDuplicate, + omitIdFromStringRecord, +} from "../../../../../lib/create/duplicateMethodCardModalDraft"; import type { CommunicationMethodDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; -import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange"; +import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; +import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { + captureMethodCardCustomizeSnapshot, + confirmDiscardMethodCardCustomizeSession, + isMethodCardCustomizeSessionDirty, + type MethodCardCustomizeSnapshot, + type MethodCardHeaderDraft, +} from "../../../../../lib/create/methodCardCustomizeSession"; +import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; export function CommunicationMethodsScreen() { const m = useMessages(); const comm = m.create.customRule.communication; + const modalKebabMenu = m.create.customRule.modalKebabMenu; const mdUp = useCreateFlowMdUp(); - const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); + const { state, updateState, replaceState, markCreateFlowInteraction } = + useCreateFlow(); + const pendingEphemeralDuplicateIdRef = useRef(null); + const customizeSnapshotRef = useRef< + MethodCardCustomizeSnapshot | null + >(null); const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] | null + >(null); + const [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); const selectedIds = state.selectedCommunicationMethodIds ?? []; @@ -97,24 +127,6 @@ export function CommunicationMethodsScreen() { ); - const modalConfig = pendingCardId - ? (() => { - const method = methodById.get(pendingCardId); - const alreadySelected = selectedIds.includes(pendingCardId); - return { - title: method?.label ?? comm.confirmModal.title, - description: method?.supportText ?? comm.confirmModal.description, - nextButtonText: alreadySelected - ? comm.removePlatform.nextButtonText - : comm.addPlatform.nextButtonText, - }; - })() - : { - title: comm.confirmModal.title, - description: comm.confirmModal.description, - nextButtonText: comm.confirmModal.nextButtonText, - }; - const seedDraft = useCallback( (id: string): CommunicationMethodDetailEntry => { const saved = state.communicationMethodDetailsById?.[id]; @@ -129,6 +141,10 @@ export function CommunicationMethodsScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); setPendingCardId(id); setPendingDraft(seedDraft(id)); setCreateModalOpen(true); @@ -144,17 +160,396 @@ export function CommunicationMethodsScreen() { [markCreateFlowInteraction], ); - const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( - createModalOpen ? pendingCardId : null, - ); - const customModalReadOnly = + const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); + const fieldsLocked = !modalEditUnlocked; + + const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + + const customFacetDetailsMatchPreset = useMemo(() => { + if (!pendingCardId || !pendingDraft) return false; + if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) { + return false; + } + return communicationMethodFacetMatchesPreset(pendingDraft, pendingCardId); + }, [ + pendingCardId, + pendingDraft, + state.customMethodCardMetaById, + ]); + + const modalUsesWizardFieldBlocksBody = useMemo( + () => + Boolean( + pendingCardId && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }), + ), + [ + customFacetDetailsMatchPreset, + draftFieldBlocks, + modalEditUnlocked, + pendingCardId, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], + ); const handleCreateModalClose = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + const ephemeralId = pendingEphemeralDuplicateIdRef.current; + if (ephemeralId) { + pendingEphemeralDuplicateIdRef.current = null; + replaceState((prev) => ({ + ...prev, + customMethodCardMetaById: omitIdFromStringRecord( + prev.customMethodCardMetaById, + ephemeralId, + ), + communicationMethodDetailsById: omitIdFromStringRecord( + prev.communicationMethodDetailsById, + ephemeralId, + ), + customMethodCardFieldBlocksById: omitIdFromStringRecord( + prev.customMethodCardFieldBlocksById, + ephemeralId, + ), + })); + } setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - }, []); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + replaceState, + ]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked) { + return; + } + const snap = customizeSnapshotRef.current; + if (!snap) { + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if ( + isMethodCardCustomizeSessionDirty( + snap, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setPendingDraft(structuredClone(snap.pendingDraft)); + setDraftFieldBlocks(null); + setModalEditUnlocked(false); + customizeSnapshotRef.current = null; + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + ]); + + const handleRemoveSelectedFromModal = useCallback(() => { + if (!pendingCardId || !selectedIds.includes(pendingCardId)) { + return; + } + markCreateFlowInteraction(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + updateState( + removeMethodCardFromFacetSelection( + state, + "communication", + pendingCardId, + ), + ); + handleCreateModalClose(); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + handleCreateModalClose, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + pendingCardId, + selectedIds, + state, + updateState, + ]); + + const handleCustomize = useCallback(() => { + markCreateFlowInteraction(); + if (!pendingDraft || !pendingCardId) { + return; + } + const persistedBlocks = + state.customMethodCardFieldBlocksById?.[pendingCardId] ?? []; + const initialFieldBlocks = + persistedBlocks.length > 0 + ? structuredClone(persistedBlocks) + : isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? [] + : null; + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? comm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + comm.confirmModal.description, + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + initialFieldBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialFieldBlocks); + setModalEditUnlocked(true); + }, [ + comm.confirmModal.description, + comm.confirmModal.title, + markCreateFlowInteraction, + methodById, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ]); + + const handleDuplicateCustomCard = useCallback(() => { + if ( + !pendingCardId || + !isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const meta = state.customMethodCardMetaById![pendingCardId]!; + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.communicationMethodDetailsById?.[pendingCardId], + () => communicationPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.communicationMethodDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(meta.label, suffix), + supportText: meta.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + communicationMethodDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + markCreateFlowInteraction, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + draftFieldBlocks, + modalEditUnlocked, + state.communicationMethodDetailsById, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + updateState, + ]); + + const handleDuplicatePrefabCard = useCallback(() => { + if ( + !pendingCardId || + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + const method = methodById.get(pendingCardId); + if (!method || !pendingDraft) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.communicationMethodDetailsById?.[pendingCardId], + () => communicationPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.communicationMethodDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(method.label, suffix), + supportText: method.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + communicationMethodDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + methodById, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.communicationMethodDetailsById, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + updateState, + ]); + + const kebabMenuItems = useMemo( + () => + buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId + ? undefined + : isCustomMethodCardId( + pendingCardId, + state.customMethodCardMetaById, + ) + ? handleDuplicateCustomCard + : handleDuplicatePrefabCard, + showRemove: isSelectedCardModal, + onRemove: handleRemoveSelectedFromModal, + }), + [ + handleCustomize, + handleDuplicateCustomCard, + handleDuplicatePrefabCard, + handleRemoveSelectedFromModal, + isSelectedCardModal, + modalEditUnlocked, + modalKebabMenu, + pendingCardId, + state.customMethodCardMetaById, + state.editingPublishedRuleId, + ], + ); + + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const saveLabel = modalKebabMenu.saveEdits; + return { + title: meta?.label ?? method?.label ?? comm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + comm.confirmModal.description, + nextButtonText: modalEditUnlocked + ? saveLabel + : comm.addPlatform.nextButtonText, + }; + })() + : { + title: comm.confirmModal.title, + description: comm.confirmModal.description, + nextButtonText: comm.confirmModal.nextButtonText, + }; const handleCloseAddWizard = useCallback(() => { setAddCustomWizardOpen(false); @@ -207,17 +602,98 @@ export function CommunicationMethodsScreen() { return; } markCreateFlowInteraction(); + if (selectedIds.includes(pendingCardId)) { - updateState( - removeMethodCardFromFacetSelection( - state, - "communication", + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, pendingCardId, - ), - ); - handleCreateModalClose(); + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } return; } + + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + pendingCardId, + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if (!pendingDraft) { handleCreateModalClose(); return; @@ -232,10 +708,14 @@ export function CommunicationMethodsScreen() { [pendingCardId]: pendingDraft, }, }); + pendingEphemeralDuplicateIdRef.current = null; handleCreateModalClose(); }, [ + customizeHeaderDraft, + draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, + modalEditUnlocked, pendingCardId, pendingDraft, selectedIds, @@ -280,31 +760,62 @@ export function CommunicationMethodsScreen() { + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={(description) => + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, description } : null, + ) + } + /> + ) : undefined + } onNext={handleCreateModalPrimary} title={modalConfig.title} description={modalConfig.description} nextButtonText={modalConfig.nextButtonText} - showBackButton={false} + showBackButton={modalEditUnlocked} + onBack={handleCancelCustomize} + backButtonText={modalKebabMenu.cancelCustomize} + showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={kebabMenuItems} > {pendingCardId && pendingDraft ? ( - isCustomMethodCardId( - pendingCardId, - state.customMethodCardMetaById, - ) ? ( + modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) } /> ) : ( ) ) : null} diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 6e8f6b4..72ca3bd 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -12,7 +12,7 @@ * any user edits as a `conflictManagementDetailsById[id]` override. */ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; @@ -34,22 +34,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId"; +import { conflictManagementFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId"; +import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody"; import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection"; +import { + cloneMethodCardBlocksForDuplicate, + cloneMethodCardDetailsForDuplicate, + duplicateMethodCardTitle, + forkMethodCardFacetMapsForDuplicate, + omitIdFromStringRecord, +} from "../../../../../lib/create/duplicateMethodCardModalDraft"; import type { ConflictManagementDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; -import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange"; +import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; +import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { + captureMethodCardCustomizeSnapshot, + confirmDiscardMethodCardCustomizeSession, + isMethodCardCustomizeSessionDirty, + type MethodCardCustomizeSnapshot, + type MethodCardHeaderDraft, +} from "../../../../../lib/create/methodCardCustomizeSession"; +import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; export function ConflictManagementScreen() { const m = useMessages(); const cm = m.create.customRule.conflictManagement; + const modalKebabMenu = m.create.customRule.modalKebabMenu; const mdUp = useCreateFlowMdUp(); - const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); + const { state, updateState, replaceState, markCreateFlowInteraction } = + useCreateFlow(); + const pendingEphemeralDuplicateIdRef = useRef(null); + const customizeSnapshotRef = useRef< + MethodCardCustomizeSnapshot | null + >(null); const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] | null + >(null); + const [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); const selectedIds = state.selectedConflictManagementIds ?? []; @@ -94,24 +124,6 @@ export function ConflictManagementScreen() { ); - const modalConfig = pendingCardId - ? (() => { - const method = methodById.get(pendingCardId); - const alreadySelected = selectedIds.includes(pendingCardId); - return { - title: method?.label ?? cm.confirmModal.title, - description: method?.supportText ?? cm.confirmModal.description, - nextButtonText: alreadySelected - ? cm.removeApproach.nextButtonText - : cm.addApproach.nextButtonText, - }; - })() - : { - title: cm.confirmModal.title, - description: cm.confirmModal.description, - nextButtonText: cm.confirmModal.nextButtonText, - }; - const seedDraft = useCallback( (id: string): ConflictManagementDetailEntry => { const saved = state.conflictManagementDetailsById?.[id]; @@ -130,6 +142,10 @@ export function ConflictManagementScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); setPendingCardId(id); setPendingDraft(seedDraft(id)); setCreateModalOpen(true); @@ -145,17 +161,394 @@ export function ConflictManagementScreen() { [markCreateFlowInteraction], ); - const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( - createModalOpen ? pendingCardId : null, - ); - const customModalReadOnly = + const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); + const fieldsLocked = !modalEditUnlocked; + + const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + + const customFacetDetailsMatchPreset = useMemo(() => { + if (!pendingCardId || !pendingDraft) return false; + if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) { + return false; + } + return conflictManagementFacetMatchesPreset(pendingDraft, pendingCardId); + }, [ + pendingCardId, + pendingDraft, + state.customMethodCardMetaById, + ]); + + const modalUsesWizardFieldBlocksBody = useMemo( + () => + Boolean( + pendingCardId && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }), + ), + [ + customFacetDetailsMatchPreset, + draftFieldBlocks, + modalEditUnlocked, + pendingCardId, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], + ); const handleCreateModalClose = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + const ephemeralId = pendingEphemeralDuplicateIdRef.current; + if (ephemeralId) { + pendingEphemeralDuplicateIdRef.current = null; + replaceState((prev) => ({ + ...prev, + customMethodCardMetaById: omitIdFromStringRecord( + prev.customMethodCardMetaById, + ephemeralId, + ), + conflictManagementDetailsById: omitIdFromStringRecord( + prev.conflictManagementDetailsById, + ephemeralId, + ), + customMethodCardFieldBlocksById: omitIdFromStringRecord( + prev.customMethodCardFieldBlocksById, + ephemeralId, + ), + })); + } setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - }, []); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + replaceState, + ]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked) { + return; + } + const snap = customizeSnapshotRef.current; + if (!snap) { + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if ( + isMethodCardCustomizeSessionDirty( + snap, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setPendingDraft(structuredClone(snap.pendingDraft)); + setDraftFieldBlocks(null); + setModalEditUnlocked(false); + customizeSnapshotRef.current = null; + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + ]); + + const handleRemoveSelectedFromModal = useCallback(() => { + if (!pendingCardId || !selectedIds.includes(pendingCardId)) { + return; + } + markCreateFlowInteraction(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + updateState( + removeMethodCardFromFacetSelection( + state, + "conflictManagement", + pendingCardId, + ), + ); + handleCreateModalClose(); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + handleCreateModalClose, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + pendingCardId, + selectedIds, + state, + updateState, + ]); + + const handleCustomize = useCallback(() => { + markCreateFlowInteraction(); + if (!pendingDraft || !pendingCardId) { + return; + } + const initialFieldBlocks = + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? structuredClone( + state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [], + ) + : null; + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? cm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + cm.confirmModal.description, + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + initialFieldBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialFieldBlocks); + setModalEditUnlocked(true); + }, [ + cm.confirmModal.description, + cm.confirmModal.title, + markCreateFlowInteraction, + methodById, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ]); + + const handleDuplicateCustomCard = useCallback(() => { + if ( + !pendingCardId || + !isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const meta = state.customMethodCardMetaById![pendingCardId]!; + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.conflictManagementDetailsById?.[pendingCardId], + () => conflictManagementPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.conflictManagementDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(meta.label, suffix), + supportText: meta.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + conflictManagementDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.conflictManagementDetailsById, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + updateState, + ]); + + const handleDuplicatePrefabCard = useCallback(() => { + if ( + !pendingCardId || + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + const method = methodById.get(pendingCardId); + if (!method || !pendingDraft) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.conflictManagementDetailsById?.[pendingCardId], + () => conflictManagementPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.conflictManagementDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(method.label, suffix), + supportText: method.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + conflictManagementDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + methodById, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.conflictManagementDetailsById, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + updateState, + ]); + + const kebabMenuItems = useMemo( + () => + buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId + ? undefined + : isCustomMethodCardId( + pendingCardId, + state.customMethodCardMetaById, + ) + ? handleDuplicateCustomCard + : handleDuplicatePrefabCard, + showRemove: isSelectedCardModal, + onRemove: handleRemoveSelectedFromModal, + }), + [ + handleCustomize, + handleDuplicateCustomCard, + handleDuplicatePrefabCard, + handleRemoveSelectedFromModal, + isSelectedCardModal, + modalEditUnlocked, + modalKebabMenu, + pendingCardId, + state.customMethodCardMetaById, + state.editingPublishedRuleId, + ], + ); + + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const saveLabel = modalKebabMenu.saveEdits; + return { + title: meta?.label ?? method?.label ?? cm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + cm.confirmModal.description, + nextButtonText: modalEditUnlocked + ? saveLabel + : cm.addApproach.nextButtonText, + }; + })() + : { + title: cm.confirmModal.title, + description: cm.confirmModal.description, + nextButtonText: cm.confirmModal.nextButtonText, + }; const handleCloseAddWizard = useCallback(() => { setAddCustomWizardOpen(false); @@ -208,17 +601,98 @@ export function ConflictManagementScreen() { return; } markCreateFlowInteraction(); + if (selectedIds.includes(pendingCardId)) { - updateState( - removeMethodCardFromFacetSelection( - state, - "conflictManagement", + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, pendingCardId, - ), - ); - handleCreateModalClose(); + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } return; } + + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + pendingCardId, + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if (!pendingDraft) { handleCreateModalClose(); return; @@ -233,10 +707,14 @@ export function ConflictManagementScreen() { [pendingCardId]: pendingDraft, }, }); + pendingEphemeralDuplicateIdRef.current = null; handleCreateModalClose(); }, [ + customizeHeaderDraft, + draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, + modalEditUnlocked, pendingCardId, pendingDraft, selectedIds, @@ -281,31 +759,62 @@ export function ConflictManagementScreen() { + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={(description) => + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, description } : null, + ) + } + /> + ) : undefined + } onNext={handleCreateModalPrimary} title={modalConfig.title} description={modalConfig.description} nextButtonText={modalConfig.nextButtonText} - showBackButton={false} + showBackButton={modalEditUnlocked} + onBack={handleCancelCustomize} + backButtonText={modalKebabMenu.cancelCustomize} + showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={kebabMenuItems} > {pendingCardId && pendingDraft ? ( - isCustomMethodCardId( - pendingCardId, - state.customMethodCardMetaById, - ) ? ( + modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) } /> ) : ( ) ) : null} diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 599931a..55d96dc 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -13,7 +13,7 @@ * DB-driven content. */ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; @@ -35,22 +35,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId"; +import { membershipMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId"; +import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody"; import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection"; +import { + cloneMethodCardBlocksForDuplicate, + cloneMethodCardDetailsForDuplicate, + duplicateMethodCardTitle, + forkMethodCardFacetMapsForDuplicate, + omitIdFromStringRecord, +} from "../../../../../lib/create/duplicateMethodCardModalDraft"; import type { MembershipMethodDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; -import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange"; +import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; +import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { + captureMethodCardCustomizeSnapshot, + confirmDiscardMethodCardCustomizeSession, + isMethodCardCustomizeSessionDirty, + type MethodCardCustomizeSnapshot, + type MethodCardHeaderDraft, +} from "../../../../../lib/create/methodCardCustomizeSession"; +import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; export function MembershipMethodsScreen() { const m = useMessages(); const mem = m.create.customRule.membership; + const modalKebabMenu = m.create.customRule.modalKebabMenu; const mdUp = useCreateFlowMdUp(); - const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); + const { state, updateState, replaceState, markCreateFlowInteraction } = + useCreateFlow(); + const pendingEphemeralDuplicateIdRef = useRef(null); + const customizeSnapshotRef = useRef< + MethodCardCustomizeSnapshot | null + >(null); const [expanded, setExpanded] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(null); const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] | null + >(null); + const [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); const selectedIds = state.selectedMembershipMethodIds ?? []; @@ -95,24 +125,6 @@ export function MembershipMethodsScreen() { ); - const modalConfig = pendingCardId - ? (() => { - const method = methodById.get(pendingCardId); - const alreadySelected = selectedIds.includes(pendingCardId); - return { - title: method?.label ?? mem.confirmModal.title, - description: method?.supportText ?? mem.confirmModal.description, - nextButtonText: alreadySelected - ? mem.removePlatform.nextButtonText - : mem.addPlatform.nextButtonText, - }; - })() - : { - title: mem.confirmModal.title, - description: mem.confirmModal.description, - nextButtonText: mem.confirmModal.nextButtonText, - }; - const seedDraft = useCallback( (id: string): MembershipMethodDetailEntry => { const saved = state.membershipMethodDetailsById?.[id]; @@ -127,6 +139,10 @@ export function MembershipMethodsScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); setPendingCardId(id); setPendingDraft(seedDraft(id)); setCreateModalOpen(true); @@ -142,17 +158,390 @@ export function MembershipMethodsScreen() { [markCreateFlowInteraction], ); - const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( - createModalOpen ? pendingCardId : null, - ); - const customModalReadOnly = + const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); + const fieldsLocked = !modalEditUnlocked; + + const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + + const customFacetDetailsMatchPreset = useMemo(() => { + if (!pendingCardId || !pendingDraft) return false; + if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) { + return false; + } + return membershipMethodFacetMatchesPreset(pendingDraft, pendingCardId); + }, [ + pendingCardId, + pendingDraft, + state.customMethodCardMetaById, + ]); + + const modalUsesWizardFieldBlocksBody = useMemo( + () => + Boolean( + pendingCardId && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }), + ), + [ + customFacetDetailsMatchPreset, + draftFieldBlocks, + modalEditUnlocked, + pendingCardId, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], + ); const handleCreateModalClose = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + const ephemeralId = pendingEphemeralDuplicateIdRef.current; + if (ephemeralId) { + pendingEphemeralDuplicateIdRef.current = null; + replaceState((prev) => ({ + ...prev, + customMethodCardMetaById: omitIdFromStringRecord( + prev.customMethodCardMetaById, + ephemeralId, + ), + membershipMethodDetailsById: omitIdFromStringRecord( + prev.membershipMethodDetailsById, + ephemeralId, + ), + customMethodCardFieldBlocksById: omitIdFromStringRecord( + prev.customMethodCardFieldBlocksById, + ephemeralId, + ), + })); + } setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - }, []); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + replaceState, + ]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked) { + return; + } + const snap = customizeSnapshotRef.current; + if (!snap) { + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if ( + isMethodCardCustomizeSessionDirty( + snap, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setPendingDraft(structuredClone(snap.pendingDraft)); + setDraftFieldBlocks(null); + setModalEditUnlocked(false); + customizeSnapshotRef.current = null; + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + ]); + + const handleRemoveSelectedFromModal = useCallback(() => { + if (!pendingCardId || !selectedIds.includes(pendingCardId)) { + return; + } + markCreateFlowInteraction(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + updateState( + removeMethodCardFromFacetSelection(state, "membership", pendingCardId), + ); + handleCreateModalClose(); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + handleCreateModalClose, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + pendingCardId, + selectedIds, + state, + updateState, + ]); + + const handleCustomize = useCallback(() => { + markCreateFlowInteraction(); + if (!pendingDraft || !pendingCardId) { + return; + } + const initialFieldBlocks = + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? structuredClone( + state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [], + ) + : null; + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? mem.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + mem.confirmModal.description, + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + initialFieldBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialFieldBlocks); + setModalEditUnlocked(true); + }, [ + mem.confirmModal.description, + mem.confirmModal.title, + markCreateFlowInteraction, + methodById, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ]); + + const handleDuplicateCustomCard = useCallback(() => { + if ( + !pendingCardId || + !isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const meta = state.customMethodCardMetaById![pendingCardId]!; + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.membershipMethodDetailsById?.[pendingCardId], + () => membershipPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.membershipMethodDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(meta.label, suffix), + supportText: meta.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + membershipMethodDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + state.membershipMethodDetailsById, + updateState, + ]); + + const handleDuplicatePrefabCard = useCallback(() => { + if ( + !pendingCardId || + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + const method = methodById.get(pendingCardId); + if (!method || !pendingDraft) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.membershipMethodDetailsById?.[pendingCardId], + () => membershipPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.membershipMethodDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(method.label, suffix), + supportText: method.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + membershipMethodDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + methodById, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + state.membershipMethodDetailsById, + updateState, + ]); + + const kebabMenuItems = useMemo( + () => + buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId + ? undefined + : isCustomMethodCardId( + pendingCardId, + state.customMethodCardMetaById, + ) + ? handleDuplicateCustomCard + : handleDuplicatePrefabCard, + showRemove: isSelectedCardModal, + onRemove: handleRemoveSelectedFromModal, + }), + [ + handleCustomize, + handleDuplicateCustomCard, + handleDuplicatePrefabCard, + handleRemoveSelectedFromModal, + isSelectedCardModal, + modalEditUnlocked, + modalKebabMenu, + pendingCardId, + state.customMethodCardMetaById, + state.editingPublishedRuleId, + ], + ); + + const modalConfig = pendingCardId + ? (() => { + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const saveLabel = modalKebabMenu.saveEdits; + return { + title: meta?.label ?? method?.label ?? mem.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + mem.confirmModal.description, + nextButtonText: modalEditUnlocked + ? saveLabel + : mem.addPlatform.nextButtonText, + }; + })() + : { + title: mem.confirmModal.title, + description: mem.confirmModal.description, + nextButtonText: mem.confirmModal.nextButtonText, + }; const handleCloseAddWizard = useCallback(() => { setAddCustomWizardOpen(false); @@ -205,13 +594,98 @@ export function MembershipMethodsScreen() { return; } markCreateFlowInteraction(); + if (selectedIds.includes(pendingCardId)) { - updateState( - removeMethodCardFromFacetSelection(state, "membership", pendingCardId), - ); - handleCreateModalClose(); + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + pendingCardId, + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } return; } + + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + pendingCardId, + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if (!pendingDraft) { handleCreateModalClose(); return; @@ -226,10 +700,14 @@ export function MembershipMethodsScreen() { [pendingCardId]: pendingDraft, }, }); + pendingEphemeralDuplicateIdRef.current = null; handleCreateModalClose(); }, [ + customizeHeaderDraft, + draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, + modalEditUnlocked, pendingCardId, pendingDraft, selectedIds, @@ -274,31 +752,62 @@ export function MembershipMethodsScreen() { + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={(description) => + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, description } : null, + ) + } + /> + ) : undefined + } onNext={handleCreateModalPrimary} title={modalConfig.title} description={modalConfig.description} nextButtonText={modalConfig.nextButtonText} - showBackButton={false} + showBackButton={modalEditUnlocked} + onBack={handleCancelCustomize} + backButtonText={modalKebabMenu.cancelCustomize} + showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={kebabMenuItems} > {pendingCardId && pendingDraft ? ( - isCustomMethodCardId( - pendingCardId, - state.customMethodCardMetaById, - ) ? ( + modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) } /> ) : ( ) ) : null} diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index d17a4e8..41c05c2 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -87,7 +87,7 @@ export function FinalReviewScreen({ }: { variant?: "default" | "editPublished"; } = {}) { - const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); + const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow(); const { goToStep } = useCreateFlowNavigation(); const mdUp = useCreateFlowMdUp(); const t = useTranslation("create.reviewAndComplete.finalReview"); @@ -96,11 +96,11 @@ export function FinalReviewScreen({ /** * 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 FinalReviewChipEditModal} — core values + method chips: kebab + * Customize / Remove; values also offer Duplicate under the five-chip cap. + * Save respects the same unlock/dirty rules as the facet create modals; + * writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`, + * and field blocks on Save. * - {@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). @@ -268,6 +268,9 @@ export function FinalReviewScreen({ target={activeEditTarget} state={state} onSave={handleSave} + replaceState={replaceState} + onInteract={markCreateFlowInteraction} + onEditTargetChange={setActiveEditTarget} /> (null); + const customizeSnapshotRef = useRef< + MethodCardCustomizeSnapshot | null + >(null); const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState( [], ); @@ -55,6 +79,12 @@ export function DecisionApproachesScreen() { const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] | null + >(null); + const [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); const selectedIds = state.selectedDecisionApproachIds ?? []; @@ -126,6 +156,10 @@ export function DecisionApproachesScreen() { const handleCardSelect = useCallback( (id: string) => { markCreateFlowInteraction(); + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); setPendingCardId(id); setPendingDraft(seedDraft(id)); setCreateModalOpen(true); @@ -141,23 +175,378 @@ export function DecisionApproachesScreen() { [markCreateFlowInteraction], ); - const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( - createModalOpen ? pendingCardId : null, - ); - const customModalReadOnly = + const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); + const fieldsLocked = !modalEditUnlocked; + + const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + + const customFacetDetailsMatchPreset = useMemo(() => { + if (!pendingCardId || !pendingDraft) return false; + if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) { + return false; + } + return decisionApproachFacetMatchesPreset(pendingDraft, pendingCardId); + }, [ + pendingCardId, + pendingDraft, + state.customMethodCardMetaById, + ]); + + const modalUsesWizardFieldBlocksBody = useMemo( + () => + Boolean( + pendingCardId && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }), + ), + [ + customFacetDetailsMatchPreset, + draftFieldBlocks, + modalEditUnlocked, + pendingCardId, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], + ); + + const handleCreateModalClose = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + const ephemeralId = pendingEphemeralDuplicateIdRef.current; + if (ephemeralId) { + pendingEphemeralDuplicateIdRef.current = null; + replaceState((prev) => ({ + ...prev, + customMethodCardMetaById: omitIdFromStringRecord( + prev.customMethodCardMetaById, + ephemeralId, + ), + decisionApproachDetailsById: omitIdFromStringRecord( + prev.decisionApproachDetailsById, + ephemeralId, + ), + customMethodCardFieldBlocksById: omitIdFromStringRecord( + prev.customMethodCardFieldBlocksById, + ephemeralId, + ), + })); + } + setCreateModalOpen(false); + setPendingCardId(null); + setPendingDraft(null); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + replaceState, + ]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked) { + return; + } + const snap = customizeSnapshotRef.current; + if (!snap) { + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if ( + isMethodCardCustomizeSessionDirty( + snap, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + ) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setPendingDraft(structuredClone(snap.pendingDraft)); + setDraftFieldBlocks(null); + setModalEditUnlocked(false); + customizeSnapshotRef.current = null; + setCustomizeHeaderDraft(null); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + ]); + + const handleRemoveSelectedFromModal = useCallback(() => { + if (!pendingCardId || !selectedIds.includes(pendingCardId)) { + return; + } + markCreateFlowInteraction(); + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + customizeSnapshotRef.current = null; + updateState( + removeMethodCardFromFacetSelection( + state, + "decisionApproaches", + pendingCardId, + ), + ); + handleCreateModalClose(); + }, [ + customizeHeaderDraft, + draftFieldBlocks, + handleCreateModalClose, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + pendingDraft, + pendingCardId, + selectedIds, + state, + updateState, + ]); + + const handleCustomize = useCallback(() => { + markCreateFlowInteraction(); + if (!pendingDraft || !pendingCardId) { + return; + } + const initialFieldBlocks = + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? structuredClone( + state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [], + ) + : null; + const method = methodById.get(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? da.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + da.confirmModal.description, + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + initialFieldBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialFieldBlocks); + setModalEditUnlocked(true); + }, [ + da.confirmModal.description, + da.confirmModal.title, + markCreateFlowInteraction, + methodById, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ]); + + const handleDuplicateCustomCard = useCallback(() => { + if ( + !pendingCardId || + !isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const meta = state.customMethodCardMetaById![pendingCardId]!; + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.decisionApproachDetailsById?.[pendingCardId], + () => decisionApproachPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.decisionApproachDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(meta.label, suffix), + supportText: meta.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + decisionApproachDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + state.decisionApproachDetailsById, + updateState, + ]); + + const handleDuplicatePrefabCard = useCallback(() => { + if ( + !pendingCardId || + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ) { + return; + } + const method = methodById.get(pendingCardId); + if (!method || !pendingDraft) { + return; + } + markCreateFlowInteraction(); + const newId = crypto.randomUUID(); + const detailsClone = cloneMethodCardDetailsForDuplicate( + pendingDraft, + state.decisionApproachDetailsById?.[pendingCardId], + () => decisionApproachPresetFor(newId), + ); + const blocksClone = structuredClone( + modalEditUnlocked && + draftFieldBlocks !== null && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + ? draftFieldBlocks + : cloneMethodCardBlocksForDuplicate( + state.customMethodCardFieldBlocksById, + pendingCardId, + ), + ); + const suffix = modalKebabMenu.duplicateTitleSuffix; + const priorEphemeral = pendingEphemeralDuplicateIdRef.current; + const maps = forkMethodCardFacetMapsForDuplicate({ + customMethodCardMetaById: state.customMethodCardMetaById, + facetDetailsById: state.decisionApproachDetailsById, + customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById, + omitId: priorEphemeral, + }); + maps.customMethodCardMetaById[newId] = { + label: duplicateMethodCardTitle(method.label, suffix), + supportText: method.supportText, + }; + maps.facetDetailsById[newId] = detailsClone; + maps.customMethodCardFieldBlocksById[newId] = blocksClone; + updateState({ + customMethodCardMetaById: maps.customMethodCardMetaById, + decisionApproachDetailsById: maps.facetDetailsById, + customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById, + }); + pendingEphemeralDuplicateIdRef.current = newId; + customizeSnapshotRef.current = null; + setPendingCardId(newId); + setPendingDraft(structuredClone(detailsClone)); + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + }, [ + draftFieldBlocks, + markCreateFlowInteraction, + methodById, + modalEditUnlocked, + modalKebabMenu.duplicateTitleSuffix, + pendingCardId, + pendingDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + state.decisionApproachDetailsById, + updateState, + ]); + + const kebabMenuItems = useMemo( + () => + buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId + ? undefined + : isCustomMethodCardId( + pendingCardId, + state.customMethodCardMetaById, + ) + ? handleDuplicateCustomCard + : handleDuplicatePrefabCard, + showRemove: isSelectedCardModal, + onRemove: handleRemoveSelectedFromModal, + }), + [ + handleCustomize, + handleDuplicateCustomCard, + handleDuplicatePrefabCard, + handleRemoveSelectedFromModal, + isSelectedCardModal, + modalEditUnlocked, + modalKebabMenu, + pendingCardId, + state.customMethodCardMetaById, + state.editingPublishedRuleId, + ], + ); const handleToggleExpand = useCallback(() => { markCreateFlowInteraction(); setExpanded((prev) => !prev); }, [markCreateFlowInteraction]); - const handleCreateModalClose = useCallback(() => { - setCreateModalOpen(false); - setPendingCardId(null); - setPendingDraft(null); - }, []); - const handleCloseAddWizard = useCallback(() => { setAddCustomWizardOpen(false); }, []); @@ -209,17 +598,98 @@ export function DecisionApproachesScreen() { return; } markCreateFlowInteraction(); + if (selectedIds.includes(pendingCardId)) { - updateState( - removeMethodCardFromFacetSelection( - state, - "decisionApproaches", + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, pendingCardId, - ), - ); - handleCreateModalClose(); + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } return; } + + if (modalEditUnlocked) { + if (!customizeHeaderDraft) { + return; + } + const nextMeta = methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + pendingCardId, + customizeHeaderDraft, + ); + if ( + pendingCardId && + isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) && + usesWizardFieldBlocksModalBody({ + methodId: pendingCardId, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked, + draftFieldBlocks, + customFacetDetailsMatchPreset, + }) + ) { + updateState({ + customMethodCardMetaById: nextMeta, + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + }); + } else if (pendingDraft) { + updateState({ + customMethodCardMetaById: nextMeta, + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [pendingCardId]: pendingDraft, + }, + }); + } + customizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setDraftFieldBlocks(null); + setCustomizeHeaderDraft(null); + return; + } + if (!pendingDraft) { handleCreateModalClose(); return; @@ -234,10 +704,14 @@ export function DecisionApproachesScreen() { [pendingCardId]: pendingDraft, }, }); + pendingEphemeralDuplicateIdRef.current = null; handleCreateModalClose(); }, [ + customizeHeaderDraft, + draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, + modalEditUnlocked, pendingCardId, pendingDraft, selectedIds, @@ -246,14 +720,18 @@ export function DecisionApproachesScreen() { ]); const modalConfig = pendingCardId - ? (() => { + ? (() => { const method = methodById.get(pendingCardId); - const alreadySelected = selectedIds.includes(pendingCardId); + const meta = state.customMethodCardMetaById?.[pendingCardId]; + const saveLabel = modalKebabMenu.saveEdits; return { - title: method?.label ?? da.confirmModal.title, - description: method?.supportText ?? da.confirmModal.description, - nextButtonText: alreadySelected - ? da.removeApproach.nextButtonText + title: meta?.label ?? method?.label ?? da.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + da.confirmModal.description, + nextButtonText: modalEditUnlocked + ? saveLabel : da.addApproach.nextButtonText, }; })() @@ -320,31 +798,62 @@ export function DecisionApproachesScreen() { + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={(description) => + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, description } : null, + ) + } + /> + ) : undefined + } onNext={handleCreateModalPrimary} title={modalConfig.title} description={modalConfig.description} nextButtonText={modalConfig.nextButtonText} - showBackButton={false} + showBackButton={modalEditUnlocked} + onBack={handleCancelCustomize} + backButtonText={modalKebabMenu.cancelCustomize} + showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={kebabMenuItems} > {pendingCardId && pendingDraft ? ( - isCustomMethodCardId( - pendingCardId, - state.customMethodCardMetaById, - ) ? ( + modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) } /> ) : ( ) ) : null} diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index deab1da..e141f77 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import MultiSelect from "../../../../components/controls/MultiSelect"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import Create from "../../../../components/modals/Create"; @@ -15,8 +15,23 @@ import type { import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CoreValueEditFields } from "../../components/methodEditFields"; +import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; +import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; +import { + captureMethodCardCustomizeSnapshot, + confirmDiscardMethodCardCustomizeSession, + isMethodCardCustomizeSessionDirty, + type MethodCardCustomizeSnapshot, + type MethodCardHeaderDraft, +} from "../../../../../lib/create/methodCardCustomizeSession"; +import { + duplicateCoreValueChipInDraft, + MAX_SELECTED_CORE_VALUES, + removeCoreValueChipFromDraft, +} from "../../../../../lib/create/coreValueChipFacet"; +import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft"; -const MAX_CORE_VALUES = 5; +const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES; /** * Why three sessions, not two: @@ -80,12 +95,18 @@ const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" }; export function CoreValuesSelectScreen() { const m = useMessages(); const cv = m.create.customRule.coreValues; + const modalKebabMenu = m.create.customRule.modalKebabMenu; const presets = useMemo( () => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]), [cv.values], ); - const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); + const { markCreateFlowInteraction, updateState, replaceState, state } = + useCreateFlow(); + + const coreCustomizeSnapshotRef = + useRef | null>(null); + const pendingEphemeralCoreDuplicateRef = useRef(null); const [coreValueOptions, setCoreValueOptions] = useState(() => buildCoreValueChipOptionsFromDraft( @@ -100,6 +121,9 @@ export function CoreValuesSelectScreen() { ); const [modalSession, setModalSession] = useState(null); const [draft, setDraft] = useState(EMPTY_DETAIL); + const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [customizeHeaderDraft, setCustomizeHeaderDraft] = + useState(null); useEffect(() => { setCoreValueOptions( @@ -158,10 +182,18 @@ export function CoreValuesSelectScreen() { ); const openModal = useCallback( - (chipId: string, session: ModalSession, valueLabel: string) => { - setDraft(getInitialTexts(chipId, valueLabel)); + ( + chipId: string, + session: ModalSession, + valueLabel: string, + seedDetail?: CoreValueDetailEntry, + ) => { + setDraft(seedDetail ?? getInitialTexts(chipId, valueLabel)); setActiveModalChipId(chipId); setModalSession(session); + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + coreCustomizeSnapshotRef.current = null; markCreateFlowInteraction(); }, [getInitialTexts, markCreateFlowInteraction], @@ -175,46 +207,347 @@ export function CoreValuesSelectScreen() { [markCreateFlowInteraction], ); - const handleModalDismiss = useCallback(() => { - if (activeModalChipId && modalSession === "pending") { + const resetCustomizeSession = useCallback(() => { + coreCustomizeSnapshotRef.current = null; + setModalEditUnlocked(false); + setCustomizeHeaderDraft(null); + }, []); + + const finalizeModalDismiss = useCallback(() => { + pendingEphemeralCoreDuplicateRef.current = null; + resetCustomizeSession(); + setActiveModalChipId(null); + setModalSession(null); + }, [resetCustomizeSession]); + + const handleCustomize = useCallback(() => { + if (!activeModalChipId) return; + const chipLabelNow = + coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; + if (!chipLabelNow) return; + markCreateFlowInteraction(); + const headerDraft: MethodCardHeaderDraft = { + title: chipLabelNow, + description: "", + }; + coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft, + null, + headerDraft, + ); + setCustomizeHeaderDraft(headerDraft); + setModalEditUnlocked(true); + }, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]); + + const handleCancelCustomize = useCallback(() => { + if (!modalEditUnlocked) return; + const snap = coreCustomizeSnapshotRef.current; + if (!snap) { + resetCustomizeSession(); + return; + } + if ( + isMethodCardCustomizeSessionDirty(snap, draft, null, customizeHeaderDraft) && + !window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) + ) { + return; + } + setDraft(structuredClone(snap.pendingDraft)); + resetCustomizeSession(); + }, [ + customizeHeaderDraft, + draft, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + resetCustomizeSession, + ]); + + const syncLabelFromCustomizeHeaderToOptions = useCallback(() => { + if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions; + const trimmed = customizeHeaderDraft.title.trim(); + if (!trimmed) return coreValueOptions; + return coreValueOptions.map((opt) => + opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt, + ); + }, [activeModalChipId, customizeHeaderDraft, coreValueOptions]); + + const handleDuplicateCoreChip = useCallback(() => { + if (!activeModalChipId || !modalSession) return; + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + markCreateFlowInteraction(); + const priorEphemeral = pendingEphemeralCoreDuplicateRef.current; + let outcome: ReturnType | null = null; + replaceState((prev) => { + const base = + priorEphemeral != null + ? { ...prev, ...removeCoreValueChipFromDraft(prev, priorEphemeral) } + : prev; + const res = duplicateCoreValueChipInDraft( + base, + activeModalChipId, + modalKebabMenu.duplicateTitleSuffix, + ); + if (!res) { + return base; + } + outcome = res; + return { ...base, ...res.patch }; + }); + if (!outcome) { + return; + } + resetCustomizeSession(); + pendingEphemeralCoreDuplicateRef.current = outcome.newId; + openModal( + outcome.newId, + "editing", + outcome.newLabel, + structuredClone(draft), + ); + }, [ + activeModalChipId, + customizeHeaderDraft, + draft, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + modalKebabMenu.duplicateTitleSuffix, + modalSession, + openModal, + replaceState, + resetCustomizeSession, + ]); + + const handleRemoveFromKebab = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + markCreateFlowInteraction(); + + const ep = pendingEphemeralCoreDuplicateRef.current; + if (ep && activeModalChipId === ep) { + replaceState((prev) => ({ + ...prev, + ...removeCoreValueChipFromDraft(prev, ep), + })); + finalizeModalDismiss(); + return; + } + + if (modalSession === "pending") { const next = coreValueOptions.map((opt) => opt.id === activeModalChipId ? { ...opt, state: "unselected" as const } : opt, ); persistCoreValues(next); - } else if (activeModalChipId && modalSession === "customPending") { - // Custom chip never confirmed via Add Value — drop it from both - // the local options and the create-flow draft so refresh / back - // navigation doesn't resurrect a phantom chip. + } else if (modalSession === "customPending") { + const next = coreValueOptions.filter((opt) => opt.id !== activeModalChipId); + persistCoreValues(next); + } else if (modalSession === "editing" && activeModalChipId) { + const nextFiltered = coreValueOptions.filter( + (opt) => opt.id !== activeModalChipId, + ); + markCreateFlowInteraction(); + replaceState((prev) => ({ + ...prev, + selectedCoreValueIds: selectedIdsFromOptions(nextFiltered), + coreValuesChipsSnapshot: + chipOptionsToSnapshotRows(nextFiltered), + coreValueDetailsByChipId: + omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId), + })); + setCoreValueOptions(nextFiltered); + } + finalizeModalDismiss(); + }, [ + activeModalChipId, + coreValueOptions, + customizeHeaderDraft, + draft, + finalizeModalDismiss, + markCreateFlowInteraction, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + modalSession, + persistCoreValues, + replaceState, + modalSession, + persistCoreValues, + ]); + + const handleModalDismiss = useCallback(() => { + if ( + !confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked, + coreCustomizeSnapshotRef.current, + draft, + null, + customizeHeaderDraft, + modalKebabMenu.discardUnsavedCustomizeChanges, + ) + ) { + return; + } + + const ep = pendingEphemeralCoreDuplicateRef.current; + if (ep) { + replaceState((prev) => ({ + ...prev, + ...removeCoreValueChipFromDraft(prev, ep), + })); + } + + if (modalSession === "pending" && activeModalChipId) { + const next = coreValueOptions.map((opt) => + opt.id === activeModalChipId + ? { ...opt, state: "unselected" as const } + : opt, + ); + persistCoreValues(next); + } else if (modalSession === "customPending" && activeModalChipId) { const next = coreValueOptions.filter( (opt) => opt.id !== activeModalChipId, ); persistCoreValues(next); } - setActiveModalChipId(null); - setModalSession(null); - }, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]); - const handleModalConfirm = useCallback(() => { - if (!activeModalChipId) return; - markCreateFlowInteraction(); - updateState({ - coreValueDetailsByChipId: { - ...(state.coreValueDetailsByChipId ?? {}), - [activeModalChipId]: draft, - }, - }); - setActiveModalChipId(null); - setModalSession(null); + finalizeModalDismiss(); }, [ activeModalChipId, + coreValueOptions, + customizeHeaderDraft, + draft, + finalizeModalDismiss, + modalEditUnlocked, + modalKebabMenu.discardUnsavedCustomizeChanges, + modalSession, + persistCoreValues, + replaceState, + ]); + + const coreCustomizeSaveDisabled = useMemo(() => { + if (!modalEditUnlocked) return false; + const snap = coreCustomizeSnapshotRef.current; + if (!snap) return true; + return !isMethodCardCustomizeSessionDirty( + snap, + draft, + null, + customizeHeaderDraft, + ); + }, [customizeHeaderDraft, draft, modalEditUnlocked]); + + const handleModalConfirm = useCallback(() => { + if (!activeModalChipId || !modalSession) return; + + if (modalEditUnlocked && customizeHeaderDraft) { + if (coreCustomizeSaveDisabled) { + return; + } + markCreateFlowInteraction(); + pendingEphemeralCoreDuplicateRef.current = null; + const nextOpts = syncLabelFromCustomizeHeaderToOptions(); + persistCoreValues(nextOpts); + updateState({ + coreValueDetailsByChipId: { + ...(state.coreValueDetailsByChipId ?? {}), + [activeModalChipId]: draft, + }, + }); + resetCustomizeSession(); + return; + } + + if (modalSession === "pending" || modalSession === "customPending") { + markCreateFlowInteraction(); + pendingEphemeralCoreDuplicateRef.current = null; + updateState({ + coreValueDetailsByChipId: { + ...(state.coreValueDetailsByChipId ?? {}), + [activeModalChipId]: draft, + }, + }); + resetCustomizeSession(); + setActiveModalChipId(null); + setModalSession(null); + } + }, [ + activeModalChipId, + coreCustomizeSaveDisabled, + customizeHeaderDraft, draft, markCreateFlowInteraction, + modalEditUnlocked, + modalSession, + persistCoreValues, + resetCustomizeSession, state.coreValueDetailsByChipId, + syncLabelFromCustomizeHeaderToOptions, updateState, ]); + const modalChipLabel = + coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; + + const modalFieldsLocked = + !modalEditUnlocked && + Boolean( + modalSession === "pending" || + modalSession === "customPending" || + modalSession === "editing", + ); + + const showFooterPrimary = + modalEditUnlocked || + modalSession === "pending" || + modalSession === "customPending"; + + const kebabMenuItems = useMemo(() => { + if (!modalSession || !activeModalChipId) return []; + const selectedCount = coreValueOptions.filter( + (o) => o.state === "selected", + ).length; + return buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: !modalEditUnlocked, + onCustomize: handleCustomize, + onDuplicate: + modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES + ? undefined + : handleDuplicateCoreChip, + showRemove: true, + onRemove: handleRemoveFromKebab, + }); + }, [ + activeModalChipId, + coreValueOptions, + handleCustomize, + handleDuplicateCoreChip, + handleRemoveFromKebab, + modalEditUnlocked, + modalKebabMenu, + modalSession, + ]); const handleChipClick = (chipId: string) => { const target = coreValueOptions.find((o) => o.id === chipId); if (!target || target.state === "custom") return; @@ -224,12 +557,7 @@ export function CoreValuesSelectScreen() { ).length; if (target.state === "selected") { - const next: ChipOption[] = coreValueOptions.map((opt) => - opt.id === chipId - ? { ...opt, state: "unselected" as const } - : opt, - ); - persistCoreValues(next); + openModal(chipId, "editing", target.label); return; } @@ -295,9 +623,6 @@ export function CoreValuesSelectScreen() { }, }; - const modalChipLabel = - coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; - const description = ( <> @@ -348,22 +673,54 @@ export function CoreValuesSelectScreen() { onClose={handleModalDismiss} backdropVariant="blurredYellow" headerContent={ -
- + setCustomizeHeaderDraft((prev) => + prev ? { ...prev, title } : null, + ) + } + onDescriptionChange={() => {}} + showDescription={false} /> -
+ ) : ( +
+ +
+ ) + } + showBackButton={modalEditUnlocked} + onBack={handleCancelCustomize} + backButtonText={modalKebabMenu.cancelCustomize} + showNextButton={showFooterPrimary} + nextButtonDisabled={ + modalEditUnlocked && coreCustomizeSaveDisabled } - showBackButton={false} - showNextButton onNext={handleModalConfirm} - nextButtonText={detailModal.addValueButton} + nextButtonText={ + modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton + } + kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} + kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} + kebabMenuItems={ + kebabMenuItems.length > 0 ? kebabMenuItems : undefined + } ariaLabel={modalChipLabel || "Core value details"} > - +
)} diff --git a/app/(app)/create/utils/runCompletedStepExit.ts b/app/(app)/create/utils/runCompletedStepExit.ts new file mode 100644 index 0000000..685f317 --- /dev/null +++ b/app/(app)/create/utils/runCompletedStepExit.ts @@ -0,0 +1,20 @@ +import { CREATE_ROUTES } from "./createFlowPaths"; + +export type CompletedStepExitRouter = { push: (_href: string) => void }; + +/** + * Leaving `/create/completed` (post-publish shell or managing a rule from profile). + * + * Clears wizard client state only. Does **not** `DELETE /api/drafts/me` — the stored + * draft may be unrelated in-progress work for another rule (one `RuleDraft` + * row per authenticated user). + */ +export function runCompletedStepExit(opts: { + clearState: () => void; + clearAnonymousCreateFlowStorage: () => void; + router: CompletedStepExitRouter; +}): void { + opts.clearState(); + opts.clearAnonymousCreateFlowStorage(); + opts.router.push(CREATE_ROUTES.root); +} diff --git a/app/components/asset/icon/Icon.tsx b/app/components/asset/icon/Icon.tsx index 24330f2..2beb379 100644 --- a/app/components/asset/icon/Icon.tsx +++ b/app/components/asset/icon/Icon.tsx @@ -6,6 +6,7 @@ import ArrowBackIcon from "./arrow_back.svg"; import ChevronRightIcon from "./chevron_right.svg"; import ContentCopyIcon from "./content_copy.svg"; import CsvIcon from "./csv.svg"; +import CustomIcon from "./custom.svg"; import EditIcon from "./edit.svg"; import ExclamationIcon from "./exclamation.svg"; import ImageGlyphIcon from "./image.svg"; @@ -23,6 +24,7 @@ export const ICON_NAME_OPTIONS = [ "chevron_right", "content_copy", "csv", + "custom", "edit", "exclamation", "image", @@ -48,6 +50,7 @@ const iconMap: Record = { chevron_right: ChevronRightIcon, content_copy: ContentCopyIcon, csv: CsvIcon, + custom: CustomIcon, edit: EditIcon, exclamation: ExclamationIcon, image: ImageGlyphIcon, diff --git a/app/components/asset/icon/custom.svg b/app/components/asset/icon/custom.svg new file mode 100644 index 0000000..c602a4f --- /dev/null +++ b/app/components/asset/icon/custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/layout/ListItem/ListItem.types.ts b/app/components/layout/ListItem/ListItem.types.ts index eaef610..952df2a 100644 --- a/app/components/layout/ListItem/ListItem.types.ts +++ b/app/components/layout/ListItem/ListItem.types.ts @@ -7,4 +7,5 @@ export type ListItemProps = { /** Bottom divider between rows — false on the final row per Figma. */ showDivider: boolean; className?: string; + variant?: "default" | "destructive"; }; diff --git a/app/components/layout/ListItem/ListItem.view.tsx b/app/components/layout/ListItem/ListItem.view.tsx index dd7d3a0..e8106b5 100644 --- a/app/components/layout/ListItem/ListItem.view.tsx +++ b/app/components/layout/ListItem/ListItem.view.tsx @@ -10,10 +10,15 @@ export const ListItemView = memo(function ListItemView({ onClick, showDivider, className = "", + variant = "default", }: ListItemProps) { const dividerClass = showDivider ? "border-b border-solid border-[var(--color-border-default-tertiary)]" : ""; + const contentTone = + variant === "destructive" + ? "text-[var(--color-content-default-negative-primary)]" + : "text-[var(--color-content-default-primary)]"; return ( diff --git a/app/components/modals/Create/Create.container.tsx b/app/components/modals/Create/Create.container.tsx index 06e68f8..076b5c6 100644 --- a/app/components/modals/Create/Create.container.tsx +++ b/app/components/modals/Create/Create.container.tsx @@ -28,6 +28,9 @@ const CreateContainer = memo( ariaLabelledBy, backdropVariant = "default", stepper, + kebabTriggerAriaLabel, + kebabMenuAriaLabel, + kebabMenuItems, }) => { const createRef = useRef(null); const overlayRef = useRef(null); @@ -60,6 +63,9 @@ const CreateContainer = memo( overlayRef={overlayRef} backdropVariant={backdropVariant} stepper={stepper} + kebabTriggerAriaLabel={kebabTriggerAriaLabel} + kebabMenuAriaLabel={kebabMenuAriaLabel} + kebabMenuItems={kebabMenuItems} /> ); }, diff --git a/app/components/modals/Create/Create.types.ts b/app/components/modals/Create/Create.types.ts index f228f83..7a3756c 100644 --- a/app/components/modals/Create/Create.types.ts +++ b/app/components/modals/Create/Create.types.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; import type { CreateModalBackdropVariant } from "./CreateModalFrame.view"; +import type { ModalHeaderMenuItem } from "../ModalHeader/ModalHeader.types"; export interface CreateProps { isOpen: boolean; @@ -37,6 +38,9 @@ export interface CreateProps { backdropVariant?: CreateModalBackdropVariant; /** Passed through to ModalFooter; set explicitly when step visibility must not infer from steps alone. */ stepper?: boolean; + kebabTriggerAriaLabel?: string; + kebabMenuAriaLabel?: string; + kebabMenuItems?: ModalHeaderMenuItem[]; } export interface CreateViewProps { @@ -63,4 +67,7 @@ export interface CreateViewProps { overlayRef: RefObject; backdropVariant: CreateModalBackdropVariant; stepper?: boolean; + kebabTriggerAriaLabel?: string; + kebabMenuAriaLabel?: string; + kebabMenuItems?: ModalHeaderMenuItem[]; } diff --git a/app/components/modals/Create/Create.view.tsx b/app/components/modals/Create/Create.view.tsx index fcfd382..9a25e71 100644 --- a/app/components/modals/Create/Create.view.tsx +++ b/app/components/modals/Create/Create.view.tsx @@ -30,6 +30,9 @@ export function CreateView({ overlayRef, backdropVariant, stepper, + kebabTriggerAriaLabel, + kebabMenuAriaLabel, + kebabMenuItems, }: CreateViewProps) { return ( - + 0} + /> {headerContent !== undefined ? (
{headerContent}
diff --git a/app/components/modals/ModalHeader/ModalHeader.container.tsx b/app/components/modals/ModalHeader/ModalHeader.container.tsx index ab3e835..82424a1 100644 --- a/app/components/modals/ModalHeader/ModalHeader.container.tsx +++ b/app/components/modals/ModalHeader/ModalHeader.container.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo } from "react"; +import { memo, useEffect, useId, useRef, useState } from "react"; import { ModalHeaderView } from "./ModalHeader.view"; import type { ModalHeaderProps } from "./ModalHeader.types"; @@ -10,7 +10,55 @@ import type { ModalHeaderProps } from "./ModalHeader.types"; * (right) icon buttons. */ const ModalHeaderContainer = memo((props) => { - return ; + const { menuItems = [] } = props; + const hasMenu = menuItems.length > 0; + const [menuOpen, setMenuOpen] = useState(false); + const menuId = useId(); + const menuWrapRef = useRef(null); + + useEffect(() => { + if (!menuOpen || !hasMenu) return; + const onDoc = (event: MouseEvent) => { + if ( + menuWrapRef.current && + !menuWrapRef.current.contains(event.target as Node) + ) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [hasMenu, menuOpen]); + + useEffect(() => { + if (!menuOpen || !hasMenu) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMenuOpen(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [hasMenu, menuOpen]); + + return ( +
+ setMenuOpen((open) => !open) : undefined} + onMenuItemClick={ + hasMenu + ? (item) => { + item.onClick?.(); + setMenuOpen(false); + } + : undefined + } + /> +
+ ); }); ModalHeaderContainer.displayName = "ModalHeader"; diff --git a/app/components/modals/ModalHeader/ModalHeader.types.ts b/app/components/modals/ModalHeader/ModalHeader.types.ts index 7c35733..f1a9781 100644 --- a/app/components/modals/ModalHeader/ModalHeader.types.ts +++ b/app/components/modals/ModalHeader/ModalHeader.types.ts @@ -1,3 +1,14 @@ +import type { IconName } from "../../asset/icon"; + +export interface ModalHeaderMenuItem { + id: string; + label: string; + leadingIcon: IconName; + onClick?: () => void; + /** Kebab rows only; omit for default lockup styling. */ + variant?: "default" | "destructive"; +} + export interface ModalHeaderProps { onClose?: () => void; onMoreOptions?: () => void; @@ -7,5 +18,11 @@ export interface ModalHeaderProps { closeButtonAriaLabel?: string; /** When set, used for the more-options control’s accessible name (e.g. localized). */ moreOptionsAriaLabel?: string; + menuAriaLabel?: string; + menuItems?: ModalHeaderMenuItem[]; + menuId?: string; + menuOpen?: boolean; + onToggleMenu?: () => void; + onMenuItemClick?: (_item: ModalHeaderMenuItem) => void; className?: string; } diff --git a/app/components/modals/ModalHeader/ModalHeader.view.tsx b/app/components/modals/ModalHeader/ModalHeader.view.tsx index 1bc0886..6ad6597 100644 --- a/app/components/modals/ModalHeader/ModalHeader.view.tsx +++ b/app/components/modals/ModalHeader/ModalHeader.view.tsx @@ -1,3 +1,5 @@ +import ListItem from "../../layout/ListItem"; +import Popover from "../Popover"; import { getAssetPath } from "../../../../lib/assetUtils"; import type { ModalHeaderProps } from "./ModalHeader.types"; @@ -11,8 +13,16 @@ export function ModalHeaderView({ showMoreOptionsButton = true, closeButtonAriaLabel = "Close dialog", moreOptionsAriaLabel = "More options", + menuAriaLabel = "More options menu", + menuItems = [], + menuId, + menuOpen = false, + onToggleMenu, + onMenuItemClick, className = "", }: ModalHeaderProps) { + const hasMenu = menuItems.length > 0; + return (
)} + {showMoreOptionsButton && hasMenu && menuOpen ? ( +
+ + {menuItems.map((item, index) => ( + onMenuItemClick?.(item)} + /> + ))} + +
+ ) : null}
); } diff --git a/lib/create/applyFinalReviewChipEditPatch.ts b/lib/create/applyFinalReviewChipEditPatch.ts index 360671e..24944cc 100644 --- a/lib/create/applyFinalReviewChipEditPatch.ts +++ b/lib/create/applyFinalReviewChipEditPatch.ts @@ -30,12 +30,44 @@ export function applyFinalReviewChipEditPatch( current && typeof current === "object" ? (current as Record) : {}; + const snapshotLabelPatch = + patch.groupKey === "coreValues" && + "chipLabel" in patch && + typeof patch.chipLabel === "string" + ? (() => { + const trim = patch.chipLabel.trim(); + if (trim.length === 0) { + return {} as Partial; + } + const snap = [...(state.coreValuesChipsSnapshot ?? [])]; + const i = snap.findIndex((r) => r.id === patch.overrideKey); + if (i < 0) { + return {} as Partial; + } + snap[i] = { ...snap[i], label: trim }; + return { + coreValuesChipsSnapshot: snap, + } satisfies Partial; + })() + : ({} as Partial); const detailPatch: Partial = { [stateKey]: { ...record, [patch.overrideKey]: patch.value, }, + ...snapshotLabelPatch, }; + const metaFromPatch = + patch.groupKey !== "coreValues" && + "methodCardMeta" in patch && + patch.methodCardMeta !== undefined + ? { + customMethodCardMetaById: { + ...(state.customMethodCardMetaById ?? {}), + [patch.overrideKey]: patch.methodCardMeta, + } satisfies NonNullable, + } + : {}; if ( patch.groupKey !== "coreValues" && "customMethodCardFieldBlocks" in patch && @@ -43,11 +75,15 @@ export function applyFinalReviewChipEditPatch( ) { return { ...detailPatch, + ...metaFromPatch, customMethodCardFieldBlocksById: { ...(state.customMethodCardFieldBlocksById ?? {}), [patch.overrideKey]: patch.customMethodCardFieldBlocks, }, }; } + if (Object.keys(metaFromPatch).length > 0) { + return { ...detailPatch, ...metaFromPatch }; + } return detailPatch; } diff --git a/lib/create/buildFinalReviewCategories.ts b/lib/create/buildFinalReviewCategories.ts index 9db3288..d4f4be8 100644 --- a/lib/create/buildFinalReviewCategories.ts +++ b/lib/create/buildFinalReviewCategories.ts @@ -109,6 +109,33 @@ function methodsForGroup( return readMethodPresetsForFacetGroup(groupKey); } +function selectedMethodIdsForGroup( + state: CreateFlowState, + groupKey: TemplateFacetGroupKey, +): string[] | undefined { + switch (groupKey) { + case "communication": + return state.selectedCommunicationMethodIds; + case "membership": + return state.selectedMembershipMethodIds; + case "decisionApproaches": + return state.selectedDecisionApproachIds; + case "conflictManagement": + return state.selectedConflictManagementIds; + default: + return undefined; + } +} + +/** Mirrors {@link buildPublishPayload}'s `pickMethodIds` — state wins when set. */ +function pickMethodIdsForReview( + fromState: string[] | undefined, + derived: readonly string[], +): string[] { + if (fromState && fromState.length > 0) return [...fromState]; + return [...derived]; +} + /** * Resolve a preset method id from a chip label (template sections / display * enrichment where entries carry titles but not stable ids). @@ -161,18 +188,44 @@ export function buildFinalReviewCategoryRowsDetailed( 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); + + if (groupKey && groupKey !== "coreValues") { + const stateSel = selectedMethodIdsForGroup(state, groupKey); + const derivedIds: string[] = []; + for (const e of s.entries) { + const title = typeof e.title === "string" ? e.title.trim() : ""; + if (title.length === 0) continue; + const id = resolveMethodPresetIdFromLabel(title, groupKey); + if (id) derivedIds.push(id); + } + // Customize flow keeps `sections` from the template but writes real + // facet picks into `selected*MethodIds` (including custom UUID cards). + // Match publish (`pickMethodIds`): when those ids exist, drive chips + // from state + `customMethodCardMetaById`, not from section titles alone. + if (stateSel && stateSel.length > 0) { + const ids = pickMethodIdsForReview(stateSel, derivedIds); + entries.push( + ...entriesFromIds( + ids, + methods, + groupKey, + state.customMethodCardMetaById, + ), + ); + } else { + for (const e of s.entries) { + const title = typeof e.title === "string" ? e.title.trim() : ""; + if (title.length === 0) continue; + const overrideKey = overrideKeyForLabel(title, methods); + entries.push({ label: title, groupKey, overrideKey }); + } + } + } else { + for (const e of s.entries) { + const title = typeof e.title === "string" ? e.title.trim() : ""; + if (title.length === 0) continue; + entries.push({ label: title, groupKey, overrideKey: null }); } - entries.push({ label: title, groupKey, overrideKey }); } if (entries.length === 0) continue; rows.push({ name: s.categoryName, groupKey, entries }); @@ -230,11 +283,11 @@ export function buildFinalReviewCategoryRowsDetailed( * Derive the final-review Rule category rows from the current * {@link CreateFlowState}. * - * Two-mode contract, mirroring the two template entry points: + * Contract across template + customize paths: * 1. **Use without changes** — `state.sections` carries the applied template - * body; we render it verbatim (`categoryName` + entry `title`s). Core - * values still come from `buildCoreValuesForDocument` when they were - * captured separately. + * body; method facets render from section titles when `selected*MethodIds` + * were cleared (see `stripCustomRuleSelectionFields`). Core values still + * come from `buildCoreValuesForDocument` when captured separately. * 2. **Customize / plain custom-rule flow** — each Create Custom screen writes * its selection ids into a dedicated state field. We resolve those ids * against the curated message `methods[]` list to get the display labels, diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index d5a2199..4a6616b 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -153,7 +153,11 @@ export function buildPublishPayload( const methodSelections = buildMethodSelectionsForDocument(state); if (hasAnyMethodSelection(methodSelections)) { - sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections); + sections = replaceMethodSectionsWithMethodSelections( + sections, + methodSelections, + state.customMethodCardFieldBlocksById, + ); } const document: Record = { sections, coreValues }; diff --git a/lib/create/coreValueChipFacet.ts b/lib/create/coreValueChipFacet.ts new file mode 100644 index 0000000..25bf141 --- /dev/null +++ b/lib/create/coreValueChipFacet.ts @@ -0,0 +1,92 @@ +import type { + CommunityStructureChipSnapshotRow, + CreateFlowState, +} from "../../app/(app)/create/types"; +import { + duplicateMethodCardTitle, + omitIdFromStringRecord, +} from "./duplicateMethodCardModalDraft"; +import { moveFacetSelectionIdToFront } from "./methodCardSelectionOrder"; + +export const MAX_SELECTED_CORE_VALUES = 5; + +/** Remove a chip from snapshot, selection ids, and per-chip detail overrides. */ +export function removeCoreValueChipFromDraft( + state: CreateFlowState, + chipId: string, +): Partial { + const snap = state.coreValuesChipsSnapshot ?? []; + const nextSnap = snap.filter((r) => r.id !== chipId); + const sel = [...(state.selectedCoreValueIds ?? [])].filter((id) => id !== chipId); + const hadDetail = + Boolean(state.coreValueDetailsByChipId) && + Object.prototype.hasOwnProperty.call(state.coreValueDetailsByChipId, chipId); + const nextDetails = hadDetail + ? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId) + : undefined; + + const out: Partial = { + coreValuesChipsSnapshot: nextSnap, + selectedCoreValueIds: sel, + }; + + if (hadDetail) { + out.coreValueDetailsByChipId = nextDetails; + } + + return out; +} + +/** Clone a core value chip with a suffixed label; returns null when at capacity. */ +export function duplicateCoreValueChipInDraft( + state: CreateFlowState, + chipId: string, + duplicateTitleSuffix: string, +): { + patch: Partial; + newId: string; + newLabel: string; +} | null { + const sel = [...(state.selectedCoreValueIds ?? [])]; + if (sel.length >= MAX_SELECTED_CORE_VALUES) { + return null; + } + const snap = state.coreValuesChipsSnapshot ?? []; + const row = snap.find((r) => r.id === chipId); + if (!row) { + return null; + } + const rawLabel = + typeof row.label === "string" && row.label.trim().length > 0 + ? row.label.trim() + : chipId; + const newId = crypto.randomUUID(); + const newLabel = duplicateMethodCardTitle(rawLabel, duplicateTitleSuffix); + const newRow: CommunityStructureChipSnapshotRow = { + id: newId, + label: newLabel, + state: "selected", + }; + + const nextSnap = [...snap, newRow]; + const inherited = state.coreValueDetailsByChipId?.[chipId]; + const nextDetails = + inherited !== undefined + ? { + ...(state.coreValueDetailsByChipId ?? {}), + [newId]: structuredClone(inherited), + } + : { ...(state.coreValueDetailsByChipId ?? {}) }; + + return { + newId, + newLabel, + patch: { + coreValuesChipsSnapshot: nextSnap, + selectedCoreValueIds: moveFacetSelectionIdToFront(sel, newId), + ...(Object.keys(nextDetails).length > 0 + ? { coreValueDetailsByChipId: nextDetails } + : {}), + }, + }; +} diff --git a/lib/create/duplicateMethodCardModalDraft.ts b/lib/create/duplicateMethodCardModalDraft.ts new file mode 100644 index 0000000..2a431d6 --- /dev/null +++ b/lib/create/duplicateMethodCardModalDraft.ts @@ -0,0 +1,81 @@ +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; + +/** + * Localized label for a duplicated method card. Supports a "%s" placeholder or + * a suffix appended to the base label (e.g. `" (copy)"`). + */ +export function duplicateMethodCardTitle( + baseLabel: string, + duplicateTitleSuffix: string, +): string { + if (duplicateTitleSuffix.includes("%s")) { + return duplicateTitleSuffix.replaceAll("%s", baseLabel); + } + return `${baseLabel}${duplicateTitleSuffix}`; +} + +export function omitIdFromStringRecord( + record: Record | undefined, + id: string, +): Record | undefined { + if (!record || !(id in record)) { + return record; + } + const next: Record = { ...record }; + delete next[id]; + return Object.keys(next).length > 0 ? next : undefined; +} + +/** Prefer in-modal draft, then persisted facet entry; deep-clone for state writes. */ +export function cloneMethodCardDetailsForDuplicate( + pendingDraft: T | null, + persisted: T | undefined, + fallback: () => T, +): T { + const base = pendingDraft ?? persisted; + if (base === undefined || base === null) { + return structuredClone(fallback()); + } + return structuredClone(base); +} + +export function cloneMethodCardBlocksForDuplicate( + blocksById: Record | undefined, + sourceId: string, +): CustomMethodCardFieldBlock[] { + return structuredClone(blocksById?.[sourceId] ?? []); +} + +/** Shallow-copy facet maps and drop `omitId` if set (chained duplicate of staged card). */ +export function forkMethodCardFacetMapsForDuplicate(params: { + customMethodCardMetaById: + | Record + | undefined; + facetDetailsById: Record | undefined; + customMethodCardFieldBlocksById: + | Record + | undefined; + omitId: string | null; +}): { + customMethodCardMetaById: Record; + facetDetailsById: Record; + customMethodCardFieldBlocksById: Record; +} { + const customMethodCardMetaById = { + ...(params.customMethodCardMetaById ?? {}), + }; + const facetDetailsById = { ...(params.facetDetailsById ?? {}) }; + const customMethodCardFieldBlocksById = { + ...(params.customMethodCardFieldBlocksById ?? {}), + }; + if (params.omitId) { + delete customMethodCardMetaById[params.omitId]; + delete facetDetailsById[params.omitId]; + delete customMethodCardFieldBlocksById[params.omitId]; + } + return { + customMethodCardMetaById, + facetDetailsById, + customMethodCardFieldBlocksById, + }; +} diff --git a/lib/create/isCustomMethodCardId.ts b/lib/create/isCustomMethodCardId.ts index 56308d6..cb63802 100644 --- a/lib/create/isCustomMethodCardId.ts +++ b/lib/create/isCustomMethodCardId.ts @@ -1,9 +1,9 @@ import type { CreateFlowState } from "../../app/(app)/create/types"; /** - * User-authored method cards (UUID ids) register a meta row when finalized - * from {@link CustomMethodCardWizard}. Preset rows from `methods[]` never - * appear here — keeps edit surfaces from treating custom ids like presets. + * True when `customMethodCardMetaById` has an entry for this id: wizard-finalized + * custom UUIDs, duplicate prefab clones, and **preset display overrides** after the + * user saves title/description in Customize mode (see {@link mergePresetMethodsWithCustom}). */ export function isCustomMethodCardId( methodId: string, diff --git a/lib/create/mergePresetMethodsWithCustom.ts b/lib/create/mergePresetMethodsWithCustom.ts index f6886c4..f491b32 100644 --- a/lib/create/mergePresetMethodsWithCustom.ts +++ b/lib/create/mergePresetMethodsWithCustom.ts @@ -13,6 +13,15 @@ export function mergePresetMethodsWithCustom< meta: Record | undefined, ): T[] { const presetIds = new Set(presets.map((p) => p.id)); + const presetRows = presets.map((p) => { + const row = meta?.[p.id]; + if (!row) return p; + return { + ...p, + label: row.label, + supportText: row.supportText, + } as T; + }); const customRows: T[] = []; const seenCustom = new Set(); @@ -28,5 +37,5 @@ export function mergePresetMethodsWithCustom< } as T); } - return [...presets, ...customRows]; + return [...presetRows, ...customRows]; } diff --git a/lib/create/methodCardCustomizeMetaPatch.ts b/lib/create/methodCardCustomizeMetaPatch.ts new file mode 100644 index 0000000..1de1db9 --- /dev/null +++ b/lib/create/methodCardCustomizeMetaPatch.ts @@ -0,0 +1,19 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; +import type { MethodCardHeaderDraft } from "./methodCardCustomizeSession"; + +/** + * Merges edited customize header strings into persisted method-card meta. + */ +export function methodCardMetaWithCustomizeHeader( + existing: CreateFlowState["customMethodCardMetaById"], + pendingCardId: string, + header: MethodCardHeaderDraft, +): NonNullable { + return { + ...(existing ?? {}), + [pendingCardId]: { + label: header.title, + supportText: header.description, + }, + }; +} diff --git a/lib/create/methodCardCustomizeSession.ts b/lib/create/methodCardCustomizeSession.ts new file mode 100644 index 0000000..8daaf1f --- /dev/null +++ b/lib/create/methodCardCustomizeSession.ts @@ -0,0 +1,80 @@ +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; + +export type MethodCardHeaderDraft = { + title: string; + description: string; +}; + +/** Snapshot of modal-local edits taken when the user enters Customize mode. */ +export type MethodCardCustomizeSnapshot = { + pendingDraft: TDraft; + fieldBlocks: CustomMethodCardFieldBlock[] | null; + headerDraft: MethodCardHeaderDraft; +}; + +export function captureMethodCardCustomizeSnapshot( + pendingDraft: TDraft, + fieldBlocks: CustomMethodCardFieldBlock[] | null, + headerDraft: MethodCardHeaderDraft, +): MethodCardCustomizeSnapshot { + return { + pendingDraft: structuredClone(pendingDraft), + fieldBlocks: + fieldBlocks === null ? null : structuredClone(fieldBlocks), + headerDraft: { ...headerDraft }, + }; +} + +export function isMethodCardCustomizeSessionDirty( + snapshot: MethodCardCustomizeSnapshot, + pendingDraft: TDraft | null, + draftFieldBlocks: CustomMethodCardFieldBlock[] | null, + headerDraft: MethodCardHeaderDraft | null, +): boolean { + if (!pendingDraft) { + return false; + } + if ( + JSON.stringify(pendingDraft) !== JSON.stringify(snapshot.pendingDraft) + ) { + return true; + } + if (headerDraft !== null) { + if ( + headerDraft.title !== snapshot.headerDraft.title || + headerDraft.description !== snapshot.headerDraft.description + ) { + return true; + } + } + const cur = + draftFieldBlocks === null ? null : JSON.stringify(draftFieldBlocks); + const snap = + snapshot.fieldBlocks === null ? null : JSON.stringify(snapshot.fieldBlocks); + return cur !== snap; +} + +/** For Close / overlay / Escape — skip closing when user cancels the confirm. */ +export function confirmDiscardMethodCardCustomizeSession( + modalEditUnlocked: boolean, + snapshot: MethodCardCustomizeSnapshot | null, + pendingDraft: TDraft | null, + draftFieldBlocks: CustomMethodCardFieldBlock[] | null, + headerDraft: MethodCardHeaderDraft | null, + message: string, +): boolean { + if (!modalEditUnlocked || snapshot === null) { + return true; + } + if ( + !isMethodCardCustomizeSessionDirty( + snapshot, + pendingDraft, + draftFieldBlocks, + headerDraft, + ) + ) { + return true; + } + return window.confirm(message); +} diff --git a/lib/create/methodCardFacetMatchesPresetForId.ts b/lib/create/methodCardFacetMatchesPresetForId.ts new file mode 100644 index 0000000..5fee314 --- /dev/null +++ b/lib/create/methodCardFacetMatchesPresetForId.ts @@ -0,0 +1,75 @@ +import type { + CommunicationMethodDetailEntry, + ConflictManagementDetailEntry, + DecisionApproachDetailEntry, + MembershipMethodDetailEntry, +} from "../../app/(app)/create/types"; +import { + communicationPresetFor, + conflictManagementPresetFor, + decisionApproachPresetFor, + membershipPresetFor, +} from "./finalReviewChipPresets"; + +function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + return a.every((v, i) => v === b[i]); +} + +/** True when communication facet text matches {@link communicationPresetFor} for this card id. */ +export function communicationMethodFacetMatchesPreset( + details: CommunicationMethodDetailEntry | undefined, + cardId: string, +): boolean { + if (!details) return true; + const p = communicationPresetFor(cardId); + return ( + details.corePrinciple === p.corePrinciple && + details.logisticsAdmin === p.logisticsAdmin && + details.codeOfConduct === p.codeOfConduct + ); +} + +export function membershipMethodFacetMatchesPreset( + details: MembershipMethodDetailEntry | undefined, + cardId: string, +): boolean { + if (!details) return true; + const p = membershipPresetFor(cardId); + return ( + details.eligibility === p.eligibility && + details.joiningProcess === p.joiningProcess && + details.expectations === p.expectations + ); +} + +export function decisionApproachFacetMatchesPreset( + details: DecisionApproachDetailEntry | undefined, + cardId: string, +): boolean { + if (!details) return true; + const p = decisionApproachPresetFor(cardId); + return ( + details.corePrinciple === p.corePrinciple && + stringArraysEqual(details.applicableScope, p.applicableScope) && + stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) && + details.stepByStepInstructions === p.stepByStepInstructions && + details.consensusLevel === p.consensusLevel && + details.objectionsDeadlocks === p.objectionsDeadlocks + ); +} + +export function conflictManagementFacetMatchesPreset( + details: ConflictManagementDetailEntry | undefined, + cardId: string, +): boolean { + if (!details) return true; + const p = conflictManagementPresetFor(cardId); + return ( + details.corePrinciple === p.corePrinciple && + stringArraysEqual(details.applicableScope, p.applicableScope) && + stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) && + details.processProtocol === p.processProtocol && + details.restorationFallbacks === p.restorationFallbacks + ); +} diff --git a/lib/create/publishedDocumentToCreateFlowState.ts b/lib/create/publishedDocumentToCreateFlowState.ts index ae7cdcd..20f381e 100644 --- a/lib/create/publishedDocumentToCreateFlowState.ts +++ b/lib/create/publishedDocumentToCreateFlowState.ts @@ -8,6 +8,38 @@ import { } from "./customRuleFacets"; import type { PublishedMethodSelections } from "./buildPublishPayload"; import type { StoredLastPublishedRule } from "./lastPublishedRule"; +import { methodLabelFor } from "./finalReviewChipPresets"; +import type { TemplateFacetGroupKey } from "./templateReviewMapping"; + +function customMethodCardMetaFromPublishedSelections( + ms: PublishedMethodSelections, +): CreateFlowState["customMethodCardMetaById"] | undefined { + const meta: NonNullable = {}; + const absorb = ( + groupKey: TemplateFacetGroupKey, + rows: + | Array<{ + id: string; + label: string; + }> + | undefined, + ) => { + if (!rows) return; + for (const row of rows) { + const id = typeof row.id === "string" ? row.id.trim() : ""; + if (!id) continue; + if (methodLabelFor(groupKey, id).length > 0) continue; + const label = typeof row.label === "string" ? row.label.trim() : ""; + if (!label) continue; + meta[id] = { label, supportText: "" }; + } + }; + absorb("communication", ms.communication); + absorb("membership", ms.membership); + absorb("decisionApproaches", ms.decisionApproaches); + absorb("conflictManagement", ms.conflictManagement); + return Object.keys(meta).length > 0 ? meta : undefined; +} /** * True when `patch` (from {@link createFlowStateFromPublishedRule}) expects @@ -31,6 +63,26 @@ export function isPublishedRuleSelectionMissing( return false; } +/** + * True when published-rule hydration should run (or continue) — facet ids still + * empty, or {@link createFlowStateFromPublishedRule} produced + * `customMethodCardMetaById` for user-authored method UUIDs that `state` does + * not have yet (final-review chips use meta + id when no preset label exists). + */ +export function isPublishedRuleHydratePatchIncomplete( + state: CreateFlowState, + patch: Partial, +): boolean { + if (isPublishedRuleSelectionMissing(state, patch)) return true; + const pm = patch.customMethodCardMetaById; + if (!pm || Object.keys(pm).length === 0) return false; + const sm = state.customMethodCardMetaById ?? {}; + for (const key of Object.keys(pm)) { + if (!sm[key]) return true; + } + return false; +} + /** * Pin flags for method-card facets: persisted for hydration and footer Confirm. * Card Stack display pulls selections to the top whenever `selected*` ids are @@ -171,6 +223,11 @@ export function createFlowStateFromPublishedRule( ); } + const customMeta = customMethodCardMetaFromPublishedSelections(ms); + if (customMeta) { + out.customMethodCardMetaById = customMeta; + } + /** Drop template `sections` so final-review uses `methodSelections` / selected ids (edit path). */ out.sections = []; return out; diff --git a/lib/create/publishedDocumentToDisplaySections.ts b/lib/create/publishedDocumentToDisplaySections.ts index 82e59d7..cacf450 100644 --- a/lib/create/publishedDocumentToDisplaySections.ts +++ b/lib/create/publishedDocumentToDisplaySections.ts @@ -3,6 +3,7 @@ import type { CommunityRuleSection, } from "../../app/components/type/CommunityRule/CommunityRule.types"; import type { PublishedMethodSelections } from "./buildPublishPayload"; +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; import { PUBLISH_FALLBACK_OVERVIEW_CATEGORY, parseDocumentSectionsForDisplay, @@ -221,6 +222,14 @@ function parseMethodSelectionsLoose( return ms as PublishedMethodSelections; } +function parseCustomFieldBlocksByIdLoose( + document: Record, +): Record | undefined { + const raw = document.customMethodCardFieldBlocksById; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + /** * Full `CommunityRule` sections for a published `document` JSON blob: validated * `document.sections` plus synthesized categories from `document.coreValues` and @@ -253,30 +262,49 @@ export function parsePublishedDocumentForCommunityRuleDisplay( seen.add(valuesSection.categoryName); } + let displaySections = [...base, ...extra]; + const methodSelections = parseMethodSelectionsLoose(doc); + const customFieldBlocksById = parseCustomFieldBlocksByIdLoose(doc); if (methodSelections) { - const comm = sectionFromCommunication(methodSelections.communication ?? []); - if (comm && !seen.has(comm.categoryName)) { - extra.push(comm); - seen.add(comm.categoryName); - } - const mem = sectionFromMembership(methodSelections.membership ?? []); - if (mem && !seen.has(mem.categoryName)) { - extra.push(mem); - seen.add(mem.categoryName); - } - const dec = sectionFromDecision(methodSelections.decisionApproaches ?? []); - if (dec && !seen.has(dec.categoryName)) { - extra.push(dec); - seen.add(dec.categoryName); - } - const cm = sectionFromConflict(methodSelections.conflictManagement ?? []); - if (cm && !seen.has(cm.categoryName)) { - extra.push(cm); - seen.add(cm.categoryName); - } + /** + * `document.sections` can lag `document.methodSelections` (e.g. API responses + * or older rows). Do not skip merging when the category already exists — + * that hid user-authored method cards on `/create/completed`. + */ + const replaceCategory = (fresh: CommunityRuleSection | null) => { + if (!fresh) return; + displaySections = displaySections.filter( + (s) => s.categoryName !== fresh.categoryName, + ); + displaySections.push(fresh); + }; + replaceCategory( + sectionFromCommunication( + methodSelections.communication ?? [], + customFieldBlocksById, + ), + ); + replaceCategory( + sectionFromMembership( + methodSelections.membership ?? [], + customFieldBlocksById, + ), + ); + replaceCategory( + sectionFromDecision( + methodSelections.decisionApproaches ?? [], + customFieldBlocksById, + ), + ); + replaceCategory( + sectionFromConflict( + methodSelections.conflictManagement ?? [], + customFieldBlocksById, + ), + ); } - const combined = [...base, ...extra].map(enrichDisplaySection); + const combined = displaySections.map(enrichDisplaySection); return sortSectionsCanonical(combined); } diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts index 5cbafb6..ed05f4b 100644 --- a/lib/create/ruleSectionsFromMethodSelections.ts +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -4,8 +4,56 @@ import type { CommunityRuleSection, } from "../../app/components/type/CommunityRule/CommunityRule.types"; import type { PublishedMethodSelections } from "./buildPublishPayload"; +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; import { templateCategoryToGroupKey } from "./templateReviewMapping"; +/** + * Serialize wizard-authored field blocks into Community Rule labeled rows for + * read-only surfaces (completed step, exported views). Matches how those blocks + * are edited in-app; `placeholderText` holds the author's answer for text blocks. + */ +export function labeledBlocksFromCustomMethodCardFieldBlocks( + blocks: CustomMethodCardFieldBlock[], +): CommunityRuleLabeledBlock[] { + const out: CommunityRuleLabeledBlock[] = []; + for (const b of blocks) { + switch (b.kind) { + case "text": { + const body = nonEmptyTrimmed(b.placeholderText); + if (body) out.push({ label: b.blockTitle, body }); + break; + } + case "badges": { + const opts = b.options.filter((x) => typeof x === "string" && x.trim().length > 0); + if (opts.length === 0) break; + out.push({ label: b.blockTitle, body: opts.join(", ") }); + break; + } + case "upload": { + const name = nonEmptyTrimmed(b.fileName); + const url = nonEmptyTrimmed(b.assetUrl); + const body = name ?? url; + if (body) out.push({ label: b.blockTitle, body }); + break; + } + case "proportion": + out.push({ + label: b.blockTitle, + body: `${b.defaultPercent}%`, + }); + break; + default: + break; + } + } + return out; +} + +export type CommunityRuleEntryFromChipOptions = { + consensusLevelKey?: string; + customFieldBlocks?: CustomMethodCardFieldBlock[]; +}; + /** Canonical `categoryName` strings for method groups in published documents. */ export const RULE_SECTION_CATEGORY = { values: "Values", @@ -107,21 +155,35 @@ export function communityRuleEntryFromMethodChip( title: string, sections: Record, labelByKey: Record, - options?: { consensusLevelKey?: string }, + options?: CommunityRuleEntryFromChipOptions, ): CommunityRuleEntry | null { - const blocks = blocksFromKeyedRecord(sections, labelByKey, options); + const presetBlocks = blocksFromKeyedRecord( + sections, + labelByKey, + options?.consensusLevelKey + ? { consensusLevelKey: options.consensusLevelKey } + : undefined, + ); + const wizardBlocks = + options?.customFieldBlocks && options.customFieldBlocks.length > 0 + ? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks) + : []; + const blocks = [...presetBlocks, ...wizardBlocks]; if (blocks.length === 0) return null; return { title, body: "", blocks }; } export function sectionFromCommunication( ms: NonNullable, + customFieldBlocksById?: Record, ): CommunityRuleSection | null { if (ms.length === 0) return null; const entries: CommunityRuleEntry[] = []; for (const m of ms) { const sec = m.sections as unknown as Record; - const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS); + const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, { + customFieldBlocks: customFieldBlocksById?.[m.id], + }); if (e) entries.push(e); } return entries.length > 0 @@ -131,12 +193,15 @@ export function sectionFromCommunication( export function sectionFromMembership( ms: NonNullable, + customFieldBlocksById?: Record, ): CommunityRuleSection | null { if (ms.length === 0) return null; const entries: CommunityRuleEntry[] = []; for (const m of ms) { const sec = m.sections as unknown as Record; - const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS); + const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, { + customFieldBlocks: customFieldBlocksById?.[m.id], + }); if (e) entries.push(e); } return entries.length > 0 @@ -146,6 +211,7 @@ export function sectionFromMembership( export function sectionFromDecision( ms: NonNullable, + customFieldBlocksById?: Record, ): CommunityRuleSection | null { if (ms.length === 0) return null; const entries: CommunityRuleEntry[] = []; @@ -159,6 +225,7 @@ export function sectionFromDecision( delete merged.selectedApplicableScope; const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { consensusLevelKey: "consensusLevel", + customFieldBlocks: customFieldBlocksById?.[m.id], }); if (e) entries.push(e); } @@ -169,6 +236,7 @@ export function sectionFromDecision( export function sectionFromConflict( ms: NonNullable, + customFieldBlocksById?: Record, ): CommunityRuleSection | null { if (ms.length === 0) return null; const entries: CommunityRuleEntry[] = []; @@ -180,7 +248,9 @@ export function sectionFromConflict( formatScopePayload(sec.applicableScope); if (scope) merged.applicableScope = scope; delete merged.selectedApplicableScope; - const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS); + const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, { + customFieldBlocks: customFieldBlocksById?.[m.id], + }); if (e) entries.push(e); } return entries.length > 0 @@ -195,20 +265,21 @@ export function sectionFromConflict( export function replaceMethodSectionsWithMethodSelections( sections: CommunityRuleSection[], ms: PublishedMethodSelections, + customFieldBlocksById?: Record, ): CommunityRuleSection[] { return sections.map((s) => { const gk = templateCategoryToGroupKey(s.categoryName); if (gk === "communication" && ms.communication?.length) { - return sectionFromCommunication(ms.communication) ?? s; + return sectionFromCommunication(ms.communication, customFieldBlocksById) ?? s; } if (gk === "membership" && ms.membership?.length) { - return sectionFromMembership(ms.membership) ?? s; + return sectionFromMembership(ms.membership, customFieldBlocksById) ?? s; } if (gk === "decisionApproaches" && ms.decisionApproaches?.length) { - return sectionFromDecision(ms.decisionApproaches) ?? s; + return sectionFromDecision(ms.decisionApproaches, customFieldBlocksById) ?? s; } if (gk === "conflictManagement" && ms.conflictManagement?.length) { - return sectionFromConflict(ms.conflictManagement) ?? s; + return sectionFromConflict(ms.conflictManagement, customFieldBlocksById) ?? s; } return s; }); diff --git a/lib/create/usesWizardFieldBlocksModalBody.ts b/lib/create/usesWizardFieldBlocksModalBody.ts new file mode 100644 index 0000000..3ea47ee --- /dev/null +++ b/lib/create/usesWizardFieldBlocksModalBody.ts @@ -0,0 +1,49 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; +import { isCustomMethodCardId } from "./isCustomMethodCardId"; + +/** + * Create modals use {@link CustomMethodCardModalBody} when there are structured field + * blocks for this method id (wizard-finalized cards, final-review chip edits, etc.), + * including **proportion-only** layouts. + * + * Check persisted blocks **before** {@link isCustomMethodCardId}: `meta` can be absent + * while `customMethodCardFieldBlocksById[id]` is still populated (e.g. partial merges). + * + * Duplicating a preset registers `meta` for the clone but leaves blocks empty — those + * stubs keep the facet's structured edit fields until the user adds blocks (then this + * returns true once persisted blocks are non-empty). + * + * **View mode** (`modalEditUnlocked` false): when the custom card still has facet copy + * that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to + * {@link CustomMethodCardModalBody} so meta-only wizard cards show policy copy instead + * of empty preset section editors. Pass `customFacetDetailsMatchPreset: false` when the + * caller knows facet details were edited or cloned from a filled preset. + */ +export function usesWizardFieldBlocksModalBody(args: { + methodId: string; + meta: CreateFlowState["customMethodCardMetaById"]; + fieldBlocksById: CreateFlowState["customMethodCardFieldBlocksById"]; + modalEditUnlocked: boolean; + draftFieldBlocks: readonly CustomMethodCardFieldBlock[] | null; + /** When strictly `true` and modal is read-only, use wizard body for custom cards with empty blocks. */ + customFacetDetailsMatchPreset?: boolean; +}): boolean { + const persisted = args.fieldBlocksById?.[args.methodId]; + if (Array.isArray(persisted) && persisted.length > 0) { + return true; + } + if (!isCustomMethodCardId(args.methodId, args.meta)) { + return false; + } + if ( + args.modalEditUnlocked && + args.draftFieldBlocks !== null && + args.draftFieldBlocks.length > 0 + ) { + return true; + } + return ( + !args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true + ); +} diff --git a/messages/en/create/customRule/coreValues.json b/messages/en/create/customRule/coreValues.json index 6e853e0..908bb05 100644 --- a/messages/en/create/customRule/coreValues.json +++ b/messages/en/create/customRule/coreValues.json @@ -12,7 +12,8 @@ "subtitle": "Edit or add to this description to describe what this value means to your community.", "meaningLabel": "What does this value mean to your group?", "signalsLabel": "Signals of Violation", - "addValueButton": "Add Value" + "addValueButton": "Add Value", + "customizeValueNameLabel": "Value name" }, "values": [ { diff --git a/messages/en/create/customRule/customMethodCardWizard.json b/messages/en/create/customRule/customMethodCardWizard.json index ac20f2d..5720294 100644 --- a/messages/en/create/customRule/customMethodCardWizard.json +++ b/messages/en/create/customRule/customMethodCardWizard.json @@ -27,6 +27,7 @@ "finalize": "Finalize" }, "editModal": { + "noCustomFieldsYet": "No custom fields yet.", "placeholderBody": "This policy uses the title and description you set when you created it. Extra section fields from preset templates are hidden here so you are not shown empty boxes that do not match what you configured.", "readout": { "emptyValue": "—", diff --git a/messages/en/create/customRule/modalKebabMenu.json b/messages/en/create/customRule/modalKebabMenu.json new file mode 100644 index 0000000..d2b4178 --- /dev/null +++ b/messages/en/create/customRule/modalKebabMenu.json @@ -0,0 +1,16 @@ +{ + "_comment": "Shared kebab popover labels for create custom-rule modals.", + "triggerAriaLabel": "More options", + "menuAriaLabel": "Custom rule options", + "items": { + "customize": "Customize", + "duplicate": "Duplicate", + "remove": "Remove" + }, + "duplicateTitleSuffix": " (copy)", + "saveEdits": "Save", + "customizePolicyTitleLabel": "Policy title", + "customizePolicyDescriptionLabel": "Description", + "cancelCustomize": "Cancel", + "discardUnsavedCustomizeChanges": "Discard unsaved changes?" +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 0e43de1..09382e4 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -41,6 +41,7 @@ import createMembership from "./create/customRule/membership.json"; import createDecisionApproaches from "./create/customRule/decisionApproaches.json"; import createConflictManagement from "./create/customRule/conflictManagement.json"; import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json"; +import createModalKebabMenu from "./create/customRule/modalKebabMenu.json"; // create – stage 3: reviewAndComplete import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json"; @@ -97,6 +98,7 @@ export default { decisionApproaches: createDecisionApproaches, conflictManagement: createConflictManagement, customMethodCardWizard: createCustomMethodCardWizard, + modalKebabMenu: createModalKebabMenu, }, reviewAndComplete: { confirmStakeholders: createConfirmStakeholders, diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index 8954274..9b0a4ae 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect } from "react"; -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { renderWithProviders as render, screen, @@ -63,17 +63,22 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { ); 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.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); - fireEvent.change(textareas[0], { target: { value: "Custom principle" } }); + const textboxes = within(screen.getByRole("dialog")).getAllByRole("textbox"); + expect(textboxes.length).toBe(5); + const corePrincipleField = textboxes[2] as HTMLTextAreaElement; + // Preset corePrinciple must seed into the first body textarea so the user + // edits a real starting point rather than an empty field. + expect(corePrincipleField.value.length).toBeGreaterThan(0); + + fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click( - within(dialog).getByRole("button", { name: "Add Platform" }), + within(screen.getByRole("dialog")).getByRole("button", { + name: "Add Platform", + }), ); await waitFor(() => { @@ -101,11 +106,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { 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" }, - }); - + void dialog; fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); @@ -143,8 +144,201 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { const textareas = within(dialog).getAllByRole( "textbox", ) as HTMLTextAreaElement[]; + expect(textareas.length).toBe(3); expect(textareas[0].value).toBe("Saved principle"); expect(textareas[1].value).toBe("Saved logistics"); expect(textareas[2].value).toBe("Saved coc"); }); + + it("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => { + let latest: CreateFlowState = {}; + const confirmSpy = vi.spyOn(window, "confirm").mockImplementation(() => { + throw new Error("confirm should not run when customize session is clean"); + }); + render( + { + latest = s; + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + (within(screen.getByRole("dialog")).getAllByRole( + "textbox", + )[0] as HTMLTextAreaElement).disabled, + ).toBe(true); + expect(latest.communicationMethodDetailsById).toBeUndefined(); + + confirmSpy.mockRestore(); + }); + + it("Cancel customize with edits restores snapshot after confirm", async () => { + let latest: CreateFlowState = {}; + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + render( + { + latest = s; + }} + initial={{ + selectedCommunicationMethodIds: ["signal"], + communicationMethodDetailsById: { + signal: { + corePrinciple: "Saved principle", + logisticsAdmin: "Saved logistics", + codeOfConduct: "Saved coc", + }, + }, + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const textboxes = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + fireEvent.change(textboxes[2], { target: { value: "Edited principle" } }); + + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(confirmSpy).toHaveBeenCalled(); + expect( + ( + within(screen.getByRole("dialog")).getAllByRole( + "textbox", + )[0] as HTMLTextAreaElement + ).value, + ).toBe("Saved principle"); + expect( + latest.communicationMethodDetailsById?.signal?.corePrinciple, + ).toBe("Saved principle"); + + confirmSpy.mockRestore(); + }); + + it("dirty Escape close stays open when user declines discard confirm", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const textboxes = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + fireEvent.change(textboxes[2], { target: { value: "Edited principle" } }); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(confirmSpy).toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + + it("persists customized policy title for a custom UUID card on Save", async () => { + const customId = "00000000-0000-4000-8000-0000000000aa"; + let latest: CreateFlowState = {}; + render( + { + latest = s; + }} + initial={{ + selectedCommunicationMethodIds: [customId], + customMethodCardMetaById: { + [customId]: { label: "Original title", supportText: "Sub" }, + }, + communicationMethodDetailsById: { + [customId]: { + corePrinciple: "p", + logisticsAdmin: "l", + codeOfConduct: "c", + }, + }, + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Original title/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const titleInput = within(screen.getByRole("dialog")).getAllByRole( + "textbox", + )[0] as HTMLInputElement; + fireEvent.change(titleInput, { target: { value: "Renamed policy" } }); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }), + ); + + await waitFor(() => { + expect(latest.customMethodCardMetaById?.[customId]?.label).toBe( + "Renamed policy", + ); + }); + }); + + it("stores preset id title override in customMethodCardMetaById on Save", async () => { + let latest: CreateFlowState = {}; + render( + { + latest = s; + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const titleInput = within(screen.getByRole("dialog")).getAllByRole( + "textbox", + )[0] as HTMLInputElement; + fireEvent.change(titleInput, { + target: { value: "Custom Signal header" }, + }); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }), + ); + + await waitFor(() => { + expect(latest.customMethodCardMetaById?.signal?.label).toBe( + "Custom Signal header", + ); + }); + }); }); diff --git a/tests/components/CustomMethodCardFieldBlocksSummary.test.tsx b/tests/components/CustomMethodCardFieldBlocksSummary.test.tsx new file mode 100644 index 0000000..762ee1f --- /dev/null +++ b/tests/components/CustomMethodCardFieldBlocksSummary.test.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import { + renderWithProviders as render, + screen, + cleanup, + fireEvent, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import CustomMethodCardFieldBlocksSummary from "../../app/(app)/create/components/CustomMethodCardFieldBlocksSummary"; +import messages from "../../messages/en/index"; +import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; + +afterEach(() => { + cleanup(); +}); + +const uploadCopy = + messages.create.customRule.customMethodCardWizard.fieldModals.upload; + +describe("CustomMethodCardFieldBlocksSummary", () => { + it("hides Upload when an upload block already has assetUrl; shows preview and remove control", () => { + const onBlocksChange = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("img", { name: uploadCopy.uploadPreviewImageAlt }), + ).toHaveAttribute("src", "/api/uploads/test-id"); + expect( + screen.getByRole("button", { + name: uploadCopy.clearPendingUploadAriaLabel, + }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument(); + }); + + it("after remove, parent can pass cleared blocks and Upload shows again", () => { + function Harness() { + const [blocks, setBlocks] = useState([ + { + kind: "upload", + id: "u1", + blockTitle: "Attachment", + fileName: "photo.png", + assetUrl: "/api/uploads/test-id", + }, + ]); + return ( + + ); + } + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: uploadCopy.clearPendingUploadAriaLabel, + }), + ); + + expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: uploadCopy.clearPendingUploadAriaLabel, + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/CustomMethodCardModalBody.test.tsx b/tests/components/CustomMethodCardModalBody.test.tsx new file mode 100644 index 0000000..be162cc --- /dev/null +++ b/tests/components/CustomMethodCardModalBody.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + renderWithProviders as render, + screen, + cleanup, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import CustomMethodCardModalBody from "../../app/(app)/create/components/CustomMethodCardModalBody"; +import messages from "../../messages/en/index"; + +afterEach(() => { + cleanup(); +}); + +const wizard = messages.create.customRule.customMethodCardWizard; + +describe("CustomMethodCardModalBody", () => { + it("with meta and no blocks, shows policy title, description, and no-fields hint", () => { + render( + , + ); + + expect(screen.getByText("Our policy")).toBeInTheDocument(); + expect(screen.getByText("How we work")).toBeInTheDocument(); + expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument(); + }); + + it("with meta and no blocks in customize mode, omits duplicate ContentLockup but keeps hint", () => { + render( + , + ); + + expect(screen.queryByText("T")).not.toBeInTheDocument(); + expect(screen.queryByText("D")).not.toBeInTheDocument(); + expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument(); + }); + + it("without meta, falls back to placeholder", () => { + render(); + + expect(screen.getByText(wizard.editModal.placeholderBody)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index d79113d..63fdb3d 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect } from "react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { fireEvent, within } from "@testing-library/react"; import { renderWithProviders as render, @@ -185,6 +185,26 @@ describe("FinalReviewScreen — prefilled selections", () => { }); describe("FinalReviewScreen — chip detail modal", () => { + async function enterMethodCustomizeFromDialog(dialog: HTMLElement) { + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + const customize = await screen.findByRole("menuitem", { + name: /^customize$/i, + }); + fireEvent.click(customize); + } + + async function enterCoreValueCustomizeFromDialog(dialog: HTMLElement) { + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + const customize = await screen.findByRole("menuitem", { + name: /^customize$/i, + }); + fireEvent.click(customize); + } + it("opens the read-only detail modal when a chip is clicked, matching the preset copy", async () => { render(); @@ -208,6 +228,83 @@ describe("FinalReviewScreen — chip detail modal", () => { ).toBeGreaterThanOrEqual(1); }); + it("method chip modal kebab offers Customize but not Duplicate", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + await waitFor(() => { + expect( + screen.getByRole("menuitem", { name: /^customize$/i }), + ).toBeInTheDocument(); + }); + expect( + screen.queryByRole("menuitem", { name: /^duplicate$/i }), + ).not.toBeInTheDocument(); + }); + + it("values chip modal kebab offers Customize and Duplicate under the cap", async () => { + function CoreValuesHarness() { + const { replaceState } = useCreateFlow(); + useLayoutEffect(() => { + replaceState({ + selectedCoreValueIds: ["1"], + coreValuesChipsSnapshot: [ + { id: "1", label: "Accessibility", state: "selected" }, + ], + }); + }, [replaceState]); + return ; + } + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Accessibility" }), + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + await waitFor(() => { + expect( + screen.getByRole("menuitem", { name: /^customize$/i }), + ).toBeInTheDocument(); + }); + expect( + screen.getByRole("menuitem", { name: /^duplicate$/i }), + ).toBeInTheDocument(); + }); + + it("opens method chip modal read-only until Customize, then enables Save after an edit", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); + expect(principleField).toBeDisabled(); + + await enterMethodCustomizeFromDialog(dialog); + + expect( + within(dialog).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + + fireEvent.change(principleField, { target: { value: "Edited principle" } }); + await waitFor(() => { + expect( + within(dialog).getByRole("button", { name: "Save" }), + ).not.toBeDisabled(); + }); + }); + it("opens a core-values chip with the matching preset meaning/signals", async () => { function CoreValuesHarness() { const { replaceState } = useCreateFlow(); @@ -236,7 +333,7 @@ describe("FinalReviewScreen — chip detail modal", () => { ).toBeInTheDocument(); }); - it("opens the editable Save modal for a values chip (parity with method chips)", async () => { + it("opens the editable Save modal for a values chip after Customize", async () => { // Customize / plain custom-rule path: snapshot is set, sections is not. function CoreValuesHarness() { const { replaceState } = useCreateFlow(); @@ -256,6 +353,10 @@ describe("FinalReviewScreen — chip detail modal", () => { await screen.findByRole("button", { name: "Accessibility" }), ); const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + await enterCoreValueCustomizeFromDialog(dialog); expect( within(dialog).getByRole("button", { name: "Save" }), ).toBeInTheDocument(); @@ -264,7 +365,7 @@ describe("FinalReviewScreen — chip detail modal", () => { ).not.toBeInTheDocument(); }); - it("opens the editable Save modal for a values chip in the use-without-changes flow", async () => { + it("opens Save for values chip after Customize (use-without-changes seeded snapshot)", 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`. @@ -294,6 +395,10 @@ describe("FinalReviewScreen — chip detail modal", () => { await screen.findByRole("button", { name: "Accessibility" }), ); const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + await enterCoreValueCustomizeFromDialog(dialog); expect( within(dialog).getByRole("button", { name: "Save" }), ).toBeInTheDocument(); @@ -312,6 +417,16 @@ describe("FinalReviewScreen — chip detail modal", () => { * 3. Closing without Save discards every typed change. */ describe("FinalReviewScreen — chip edit modal save semantics", () => { + async function enterMethodCustomizeFromDialog(dialog: HTMLElement) { + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + const customize = await screen.findByRole("menuitem", { + name: /^customize$/i, + }); + fireEvent.click(customize); + } + const baseSelections: CreateFlowState = { title: "Oak Park Commons", selectedCommunicationMethodIds: ["signal"], @@ -331,11 +446,14 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { fireEvent.click(await screen.findByRole("button", { name: "Signal" })); const dialog = await screen.findByRole("dialog"); + await enterMethodCustomizeFromDialog(dialog); const saveButton = within(dialog).getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); - const [firstTextarea] = within(dialog).getAllByRole("textbox"); - fireEvent.change(firstTextarea, { + fireEvent.change(principleField, { target: { value: "Edited principle" }, }); @@ -359,14 +477,21 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { fireEvent.click(await screen.findByRole("button", { name: "Signal" })); const dialog = await screen.findByRole("dialog"); - const [firstTextarea] = within(dialog).getAllByRole("textbox"); - fireEvent.change(firstTextarea, { + await enterMethodCustomizeFromDialog(dialog); + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); + fireEvent.change(principleField, { target: { value: "Edited principle" }, }); fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + within(screen.getByRole("dialog")).queryByRole("button", { + name: "Save", + }), + ).not.toBeInTheDocument(); }); await waitFor(() => { expect( @@ -388,15 +513,23 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { fireEvent.click(await screen.findByRole("button", { name: "Signal" })); const dialog = await screen.findByRole("dialog"); - const [firstTextarea] = within(dialog).getAllByRole("textbox"); - fireEvent.change(firstTextarea, { + await enterMethodCustomizeFromDialog(dialog); + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); + fireEvent.change(principleField, { target: { value: "Should NOT persist" }, }); - fireEvent.keyDown(document, { key: "Escape" }); - await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + try { + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + } finally { + confirmSpy.mockRestore(); + } expect(latest.communicationMethodDetailsById).toBeUndefined(); }); @@ -424,11 +557,41 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { ); const dialog = await screen.findByRole("dialog"); expect( - within(dialog).getByText(/title and description you set/i), + within(dialog).getByText(/no custom fields yet/i), ).toBeInTheDocument(); expect(within(dialog).queryByRole("textbox")).toBeNull(); }); + it("shows custom communication chip when template sections exist (customize-from-template)", async () => { + const customId = "550e8400-e29b-41d4-a716-446655440999"; + render( + {}} + initial={{ + title: "Oak Park Commons", + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Signal", body: "…" }], + }, + ], + selectedCommunicationMethodIds: ["signal", customId], + customMethodCardMetaById: { + [customId]: { + label: "Garden IRC", + supportText: "Support line from wizard", + }, + }, + }} + />, + ); + + expect( + await screen.findByRole("button", { name: "Garden IRC" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Signal" })).toBeInTheDocument(); + }); + it("shows editable field blocks for user-authored communication chips when configured", async () => { const customId = "550e8400-e29b-41d4-a716-446655440000"; render( @@ -461,12 +624,13 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { await screen.findByRole("button", { name: "Custom Comm" }), ); const dialog = await screen.findByRole("dialog"); + await enterMethodCustomizeFromDialog(dialog); expect( - within(dialog).queryByText(/title and description you set/i), + within(dialog).queryByText(/no custom fields yet/i), ).not.toBeInTheDocument(); - const textarea = within(dialog).getByRole("textbox"); - expect(textarea).not.toBeDisabled(); - expect(textarea).toHaveValue("Detail here"); + const notesField = within(dialog).getByRole("textbox", { name: /notes/i }); + expect(notesField).not.toBeDisabled(); + expect(notesField).toHaveValue("Detail here"); }); it("persists field block edits for user-authored communication chips on Save", async () => { @@ -504,19 +668,20 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { await screen.findByRole("button", { name: "Custom Comm" }), ); const dialog = await screen.findByRole("dialog"); - const textarea = within(dialog).getByRole("textbox"); - fireEvent.change(textarea, { target: { value: "Saved detail" } }); + await enterMethodCustomizeFromDialog(dialog); + const notesField = within(dialog).getByRole("textbox", { name: /notes/i }); + fireEvent.change(notesField, { target: { value: "Saved detail" } }); fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - expect( - latest.customMethodCardFieldBlocksById?.[customId]?.[0], - ).toMatchObject({ - kind: "text", - placeholderText: "Saved detail", + expect( + latest.customMethodCardFieldBlocksById?.[customId]?.[0], + ).toMatchObject({ + kind: "text", + placeholderText: "Saved detail", + }); }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); }); }); diff --git a/tests/lib/methodCardCustomizeSession.test.ts b/tests/lib/methodCardCustomizeSession.test.ts new file mode 100644 index 0000000..9c90f31 --- /dev/null +++ b/tests/lib/methodCardCustomizeSession.test.ts @@ -0,0 +1,93 @@ +import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; +import { describe, expect, it, vi } from "vitest"; +import { + captureMethodCardCustomizeSnapshot, + confirmDiscardMethodCardCustomizeSession, + isMethodCardCustomizeSessionDirty, +} from "../../lib/create/methodCardCustomizeSession"; + +const HEADER_0 = { title: "", description: "" }; + +describe("methodCardCustomizeSession", () => { + it("reports clean session when pendingDraft and blocks match snapshot", () => { + const draft = { a: 1, b: [2] }; + const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0); + expect( + isMethodCardCustomizeSessionDirty(snap, { ...draft }, null, HEADER_0), + ).toBe(false); + }); + + it("reports dirty when pendingDraft JSON differs", () => { + const snap = captureMethodCardCustomizeSnapshot({ x: "one" }, null, HEADER_0); + expect( + isMethodCardCustomizeSessionDirty(snap, { x: "two" }, null, HEADER_0), + ).toBe(true); + }); + + it("reports dirty when field blocks differ", () => { + const before: CustomMethodCardFieldBlock[] = [ + { kind: "text", id: "b1", blockTitle: "t", placeholderText: "" }, + ]; + const snap = captureMethodCardCustomizeSnapshot({ ok: true }, before, HEADER_0); + const after: CustomMethodCardFieldBlock[] = [ + { + kind: "text", + id: "b1", + blockTitle: "t", + placeholderText: "edited", + }, + ]; + expect( + isMethodCardCustomizeSessionDirty(snap, { ok: true }, after, HEADER_0), + ).toBe(true); + }); + + it("reports dirty when header draft differs", () => { + const snap = captureMethodCardCustomizeSnapshot({ ok: true }, null, { + title: "A", + description: "B", + }); + expect( + isMethodCardCustomizeSessionDirty( + snap, + { ok: true }, + null, + { title: "A2", description: "B" }, + ), + ).toBe(true); + }); + + it("confirmDiscard skips confirm when unlocked but snapshot missing", () => { + const spy = vi.spyOn(window, "confirm"); + expect( + confirmDiscardMethodCardCustomizeSession( + true, + null, + { x: 1 }, + null, + null, + "msg", + ), + ).toBe(true); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("confirmDiscard runs confirm when dirty", () => { + const spy = vi.spyOn(window, "confirm").mockReturnValue(false); + const draft = { n: 1 }; + const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0); + expect( + confirmDiscardMethodCardCustomizeSession( + true, + snap, + { n: 2 }, + null, + HEADER_0, + "Discard?", + ), + ).toBe(false); + expect(spy).toHaveBeenCalledWith("Discard?"); + spy.mockRestore(); + }); +}); diff --git a/tests/pages/communication-methods.test.jsx b/tests/pages/communication-methods.test.jsx index 8c40bd7..4e821e7 100644 --- a/tests/pages/communication-methods.test.jsx +++ b/tests/pages/communication-methods.test.jsx @@ -40,7 +40,7 @@ describe("Create flow communication-methods page", () => { expect(within(dialog).getByText("Add Platform")).toBeInTheDocument(); }); - test("re-opening a selected method shows Remove as the modal primary action", async () => { + test("re-opening a selected method shows no modal primary; Remove is in the kebab", async () => { const user = userEvent.setup(); render(); @@ -54,11 +54,17 @@ describe("Create flow communication-methods page", () => { await user.click(signalCards[0]); const dialogAgain = screen.getByRole("dialog"); expect( - within(dialogAgain).getByRole("button", { name: "Remove" }), - ).toBeInTheDocument(); + within(dialogAgain).queryByRole("button", { name: "Remove" }), + ).not.toBeInTheDocument(); + expect( + within(dialogAgain).queryByRole("button", { name: "Add Platform" }), + ).not.toBeInTheDocument(); + + await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); + expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); }); - test("Remove in the modal deselects the method", async () => { + test("Remove from the kebab deselects the method", async () => { const user = userEvent.setup(); render(); @@ -76,12 +82,50 @@ describe("Create flow communication-methods page", () => { await user.click(signalCards[0]); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }), + within(screen.getByRole("dialog")).getByRole("button", { + name: "More options", + }), ); + await user.click(screen.getByRole("menuitem", { name: "Remove" })); expect(signalCards[0]).not.toHaveTextContent("SELECTED"); }); + test("kebab menu does not include Close", async () => { + const user = userEvent.setup(); + render(); + + const signalCards = screen.getAllByRole("button", { + name: /Signal: Encrypted messaging/, + }); + await user.click(signalCards[0]); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "More options" })); + expect( + screen.queryByRole("menuitem", { name: "Close" }), + ).not.toBeInTheDocument(); + }); + + test("unselected preset method fields are disabled until Customize", async () => { + const user = userEvent.setup(); + render(); + + const signalCards = screen.getAllByRole("button", { + name: /Signal: Encrypted messaging/, + }); + await user.click(signalCards[0]); + + const dialog = screen.getByRole("dialog"); + const textbox = within(dialog).getAllByRole("textbox")[0]; + expect(textbox).toBeDisabled(); + + await user.click(within(dialog).getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("menuitem", { name: "Customize" })); + expect( + within(screen.getByRole("dialog")).getAllByRole("textbox")[0], + ).not.toBeDisabled(); + }); + test("renders without error", () => { render(); @@ -152,7 +196,7 @@ describe("Create flow communication-methods page", () => { ).toBeInTheDocument(); }); - test("opening Create modal for custom policy shows saved field blocks", async () => { + test("opening Create modal for custom policy shows saved field blocks read-only until Customize", async () => { const user = userEvent.setup(); const initial = { selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], @@ -180,12 +224,23 @@ describe("Create flow communication-methods page", () => { const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText("Guidelines")).toBeInTheDocument(); - const textarea = within(dialog).getByRole("textbox"); - expect(textarea).not.toBeDisabled(); + const textboxesBefore = within(dialog).getAllByRole("textbox"); + expect(textboxesBefore).toHaveLength(1); + const textarea = textboxesBefore[0]; + expect(textarea).toBeDisabled(); expect(textarea).toHaveValue("Enter norms here"); + + await user.click(within(dialog).getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("menuitem", { name: "Customize" })); + + const guidelinesAfter = within(screen.getByRole("dialog")).getAllByRole( + "textbox", + )[2]; + expect(guidelinesAfter).not.toBeDisabled(); + expect(guidelinesAfter).toHaveValue("Enter norms here"); }); - test("opening Create modal for custom policy shows badge options as chips", async () => { + test("opening Create modal for custom policy shows badge options as chips read-only until Customize", async () => { const user = userEvent.setup(); const initial = { selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], @@ -213,13 +268,25 @@ describe("Create flow communication-methods page", () => { const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText("Choose channels")).toBeInTheDocument(); - const alpha = within(dialog).getByRole("button", { name: /Deselect Alpha/ }); - const beta = within(dialog).getByRole("button", { name: /Deselect Beta/ }); - expect(alpha).not.toBeDisabled(); - expect(beta).not.toBeDisabled(); + const alpha = within(dialog).getByRole("button", { name: /^Alpha$/ }); + const beta = within(dialog).getByRole("button", { name: /^Beta$/ }); + expect(alpha).toBeDisabled(); + expect(beta).toBeDisabled(); + + await user.click(within(dialog).getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("menuitem", { name: "Customize" })); + + const alphaAfter = within(screen.getByRole("dialog")).getByRole("button", { + name: /Deselect Alpha/, + }); + const betaAfter = within(screen.getByRole("dialog")).getByRole("button", { + name: /Deselect Beta/, + }); + expect(alphaAfter).not.toBeDisabled(); + expect(betaAfter).not.toBeDisabled(); }); - test("editing custom policy field blocks updates draft state", async () => { + test("editing custom policy field blocks updates draft state after Save", async () => { const user = userEvent.setup(); let latest = {}; function Probe({ initial }) { @@ -254,10 +321,16 @@ describe("Create flow communication-methods page", () => { name: /My policy: Support copy/, }); await user.click(policyTiles[0]); - const textarea = within(screen.getByRole("dialog")).getByRole("textbox"); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("menuitem", { name: "Customize" })); + + const textarea = within(dialog).getAllByRole("textbox")[2]; await user.clear(textarea); await user.type(textarea, "Updated norms"); + await user.click(within(dialog).getByRole("button", { name: "Save" })); + const row = latest.customMethodCardFieldBlocksById?.[CUSTOM_POLICY_ID]?.[0]; expect(row).toMatchObject({ kind: "text", @@ -265,4 +338,83 @@ describe("Create flow communication-methods page", () => { }); }); + test("duplicate staged copy is unselected; closing modal drops ephemeral card; duplicate again works", async () => { + const user = userEvent.setup(); + let latest = {}; + function Probe({ initial }) { + const { replaceState, state } = useCreateFlow(); + useLayoutEffect(() => { + replaceState(initial); + }, [replaceState, initial]); + useLayoutEffect(() => { + latest = state; + }, [state]); + return ; + } + const initial = { + selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], + customMethodCardMetaById: { + [CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" }, + }, + customMethodCardFieldBlocksById: { + [CUSTOM_POLICY_ID]: [ + { + kind: "text", + id: "f1", + blockTitle: "Guidelines", + placeholderText: "Enter norms here", + }, + ], + }, + }; + render(); + + const policyTiles = screen.getAllByRole("button", { + name: /My policy: Support copy/, + }); + await user.click(policyTiles[0]); + const dialog = screen.getByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + await user.click(screen.getByRole("menuitem", { name: "Duplicate" })); + + const metaAfterDup = latest.customMethodCardMetaById ?? {}; + const dupIds = Object.keys(metaAfterDup).filter( + (id) => id !== CUSTOM_POLICY_ID, + ); + expect(dupIds).toHaveLength(1); + const dupId = dupIds[0]; + expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]); + expect( + within(dialog).getByRole("button", { name: "Add Platform" }), + ).toBeInTheDocument(); + expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true); + expect(within(dialog).getByRole("heading", { level: 1 })).toHaveTextContent( + /^My policy \(copy\)$/, + ); + expect(within(dialog).getByText("Support copy")).toBeInTheDocument(); + expect(within(dialog).getByText("Guidelines")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox")).toHaveValue("Enter norms here"); + await user.click( + within(dialog).getByRole("button", { name: "Close dialog" }), + ); + + const metaAfterClose = latest.customMethodCardMetaById ?? {}; + expect(metaAfterClose[dupId]).toBeUndefined(); + expect(Object.keys(metaAfterClose)).toEqual([CUSTOM_POLICY_ID]); + + await user.click(policyTiles[0]); + const dialog2 = screen.getByRole("dialog"); + await user.click( + within(dialog2).getByRole("button", { name: "More options" }), + ); + await user.click(screen.getByRole("menuitem", { name: "Duplicate" })); + const metaSecond = latest.customMethodCardMetaById ?? {}; + const dupIds2 = Object.keys(metaSecond).filter( + (id) => id !== CUSTOM_POLICY_ID, + ); + expect(dupIds2).toHaveLength(1); + }); + }); diff --git a/tests/pages/decision-approaches.test.jsx b/tests/pages/decision-approaches.test.jsx index e5929f7..5a12158 100644 --- a/tests/pages/decision-approaches.test.jsx +++ b/tests/pages/decision-approaches.test.jsx @@ -181,7 +181,7 @@ describe("Create flow decision-approaches page", () => { expect(screen.getByText("SELECTED")).toBeInTheDocument(); }); - test("re-opening a selected approach shows Remove as the modal primary action", async () => { + test("re-opening a selected approach shows no modal primary; Remove is in the kebab", async () => { const user = userEvent.setup(); render(); @@ -199,11 +199,17 @@ describe("Create flow decision-approaches page", () => { await user.click(card); const dialogAgain = screen.getByRole("dialog"); expect( - within(dialogAgain).getByRole("button", { name: "Remove" }), - ).toBeInTheDocument(); + within(dialogAgain).queryByRole("button", { name: "Remove" }), + ).not.toBeInTheDocument(); + expect( + within(dialogAgain).queryByRole("button", { name: "Add Approach" }), + ).not.toBeInTheDocument(); + + await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); + expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); }); - test("Remove in the modal deselects the approach", async () => { + test("Remove from the kebab deselects the approach", async () => { const user = userEvent.setup(); render(); @@ -221,12 +227,37 @@ describe("Create flow decision-approaches page", () => { await user.click(card); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }), + within(screen.getByRole("dialog")).getByRole("button", { name: "More options" }), ); + await user.click(screen.getByRole("menuitem", { name: "Remove" })); expect(card).not.toHaveTextContent("SELECTED"); }); + test("when editing a published rule, method modal kebab has no Duplicate", async () => { + const user = userEvent.setup(); + render( + , + ); + + const card = screen.getByRole("button", { + name: /Lazy Consensus: A decision is assumed approved/, + }); + await user.click(card); + const dialog = await screen.findByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + expect( + screen.queryByRole("menuitem", { name: "Duplicate" }), + ).not.toBeInTheDocument(); + }); + test("message box checkboxes are interactive", async () => { const user = userEvent.setup(); render(); diff --git a/tests/unit/applyFinalReviewChipEditPatch.test.ts b/tests/unit/applyFinalReviewChipEditPatch.test.ts index e0bf6d9..775075f 100644 --- a/tests/unit/applyFinalReviewChipEditPatch.test.ts +++ b/tests/unit/applyFinalReviewChipEditPatch.test.ts @@ -166,4 +166,52 @@ describe("applyFinalReviewChipEditPatch", () => { "550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks, }); }); + + it("merges customMethodCardMetaById when the patch carries methodCardMeta", () => { + const state: CreateFlowState = { + customMethodCardMetaById: { + signal: { label: "Signal", supportText: "Old" }, + }, + }; + const patch: FinalReviewChipEditPatch = { + groupKey: "communication", + overrideKey: "signal", + value: { + corePrinciple: "p", + logisticsAdmin: "l", + codeOfConduct: "c", + }, + methodCardMeta: { label: "Signal (edited)", supportText: "New sub" }, + }; + + const result = applyFinalReviewChipEditPatch(state, patch); + + expect(result.customMethodCardMetaById).toEqual({ + signal: { label: "Signal (edited)", supportText: "New sub" }, + }); + }); + + it("updates coreValuesChipsSnapshot label when patch carries chipLabel", () => { + const state: CreateFlowState = { + coreValuesChipsSnapshot: [ + { id: "1", label: "Accessibility", state: "selected" }, + ], + coreValueDetailsByChipId: { "1": { meaning: "m", signals: "s" } }, + }; + const patch: FinalReviewChipEditPatch = { + groupKey: "coreValues", + overrideKey: "1", + value: { meaning: "m2", signals: "s2" }, + chipLabel: "A11y renamed", + }; + + const result = applyFinalReviewChipEditPatch(state, patch); + + expect(result.coreValuesChipsSnapshot).toEqual([ + { id: "1", label: "A11y renamed", state: "selected" }, + ]); + expect(result.coreValueDetailsByChipId).toEqual({ + "1": { meaning: "m2", signals: "s2" }, + }); + }); }); diff --git a/tests/unit/buildFinalReviewCategories.test.ts b/tests/unit/buildFinalReviewCategories.test.ts index f3cb8a1..808a7a6 100644 --- a/tests/unit/buildFinalReviewCategories.test.ts +++ b/tests/unit/buildFinalReviewCategories.test.ts @@ -80,7 +80,7 @@ describe("buildFinalReviewCategoriesFromState", () => { expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]); }); - it("prefers state.sections when populated (use-without-changes path)", () => { + it("uses section titles for method facets when selections were cleared (use-without-changes)", () => { const state: CreateFlowState = { sections: [ { @@ -95,10 +95,7 @@ describe("buildFinalReviewCategoriesFromState", () => { entries: [{ title: "Signal", body: "…" }], }, ], - // Selection ids must be ignored when sections is present — the - // "Use without changes" handler resets them for exactly that reason, - // but we double-check the helper honors the sections branch first. - selectedCommunicationMethodIds: ["in-person-meetings"], + selectedCommunicationMethodIds: [], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); expect(rows).toEqual([ @@ -107,6 +104,41 @@ describe("buildFinalReviewCategoriesFromState", () => { ]); }); + it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => { + const state: CreateFlowState = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Signal", body: "…" }], + }, + ], + selectedCommunicationMethodIds: ["in-person-meetings"], + }; + const rows = buildFinalReviewCategoriesFromState(state, NAMES); + expect(rows).toEqual([ + { name: "Communication", chips: ["In-Person Meetings"] }, + ]); + }); + + it("shows custom communication chips when sections exist and selections include a UUID", () => { + const customId = "00000000-0000-4000-8000-000000000099"; + const state: CreateFlowState = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Signal", body: "…" }], + }, + ], + selectedCommunicationMethodIds: ["signal", customId], + customMethodCardMetaById: { + [customId]: { label: "Garden IRC", supportText: "x" }, + }, + }; + const rows = buildFinalReviewCategoriesFromState(state, NAMES); + const comm = rows.find((r) => r.name === "Communication"); + expect(comm?.chips).toEqual(["Signal", "Garden IRC"]); + }); + it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => { const state: CreateFlowState = { sections: [ diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts index 5593f89..5aac31f 100644 --- a/tests/unit/buildPublishPayload.test.ts +++ b/tests/unit/buildPublishPayload.test.ts @@ -188,6 +188,43 @@ describe("buildPublishPayload — methodSelections", () => { expect(ms?.communication?.[0]?.label).toBe("Custom Comm"); }); + it("embeds wizard field blocks in published Communication sections for custom UUID ids", () => { + const customId = "00000000-0000-4000-8000-000000000099"; + const r = buildPublishPayload({ + title: "T", + selectedCommunicationMethodIds: [customId], + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Template row", body: "placeholder" }], + }, + ], + customMethodCardMetaById: { + [customId]: { label: "Wizard title", supportText: "" }, + }, + customMethodCardFieldBlocksById: { + [customId]: [ + { + kind: "text", + id: "b1", + blockTitle: "Field A", + placeholderText: "User-authored body", + }, + ], + }, + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const secs = r.document.sections as Array<{ + categoryName: string; + entries: Array<{ blocks?: Array<{ label: string; body: string }> }>; + }>; + const comm = secs.find((s) => s.categoryName === "Communication"); + expect(comm?.entries[0]?.blocks).toEqual([ + { label: "Field A", body: "User-authored body" }, + ]); + }); + it("emits preset-only sections when a method is selected without an override", () => { const r = buildPublishPayload({ title: "T", diff --git a/tests/unit/hooks/useCreateFlowExit.test.tsx b/tests/unit/hooks/useCreateFlowExit.test.tsx new file mode 100644 index 0000000..4cacbde --- /dev/null +++ b/tests/unit/hooks/useCreateFlowExit.test.tsx @@ -0,0 +1,82 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { CreateFlowState } from "../../../app/(app)/create/types"; + +const deleteServerDraft = vi.fn(); +const saveDraftToServer = vi.fn(); +const updatePublishedRule = vi.fn(); + +vi.mock("../../../lib/create/buildPublishPayload", () => ({ + buildPublishPayload: vi.fn(() => ({ + ok: true as const, + title: "T", + summary: "S", + document: {}, + })), +})); + +vi.mock("../../../lib/create/api", () => ({ + deleteServerDraft: (...args: unknown[]) => deleteServerDraft(...args), + saveDraftToServer: (...args: unknown[]) => saveDraftToServer(...args), + updatePublishedRule: (...args: unknown[]) => updatePublishedRule(...args), +})); + +vi.mock("../../../lib/create/lastPublishedRule", () => ({ + writeLastPublishedRule: vi.fn(), +})); + +async function loadExitHook() { + return import("../../../app/(app)/create/hooks/useCreateFlowExit"); +} + +describe("useCreateFlowExit", () => { + const router = { push: vi.fn() }; + const clearState = vi.fn(); + const user = { id: "u1", email: "a@b.c" }; + + beforeEach(async () => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("NEXT_PUBLIC_ENABLE_BACKEND_SYNC", "true"); + deleteServerDraft.mockReset(); + saveDraftToServer.mockReset(); + updatePublishedRule.mockReset(); + router.push.mockReset(); + clearState.mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not delete the server draft after updating a published rule (preserves other in-progress work)", async () => { + updatePublishedRule.mockResolvedValue({ ok: true as const }); + + const { useCreateFlowExit } = await loadExitHook(); + + const state: CreateFlowState = { + editingPublishedRuleId: "rule-1", + }; + + const { result } = renderHook(() => + useCreateFlowExit({ + state, + currentStep: "edit-rule", + clearState, + router, + user, + }), + ); + + await act(async () => { + await result.current({ saveDraft: true }); + }); + + expect(updatePublishedRule).toHaveBeenCalledWith( + "rule-1", + expect.objectContaining({ title: "T" }), + ); + expect(deleteServerDraft).not.toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledWith("/"); + }); +}); diff --git a/tests/unit/mergePresetMethodsWithCustom.test.ts b/tests/unit/mergePresetMethodsWithCustom.test.ts index ee9ac4e..5ce6eab 100644 --- a/tests/unit/mergePresetMethodsWithCustom.test.ts +++ b/tests/unit/mergePresetMethodsWithCustom.test.ts @@ -22,4 +22,23 @@ describe("mergePresetMethodsWithCustom", () => { supportText: "cx", }); }); + + it("overlays meta label/supportText onto preset ids for card display", () => { + const presets = [ + { id: "signal", label: "Signal", supportText: "preset sub" }, + ]; + const merged = mergePresetMethodsWithCustom( + presets, + ["signal"], + { + signal: { label: "Renamed", supportText: "user sub" }, + }, + ); + expect(merged).toHaveLength(1); + expect(merged[0]).toEqual({ + id: "signal", + label: "Renamed", + supportText: "user sub", + }); + }); }); diff --git a/tests/unit/methodCardFacetMatchesPresetForId.test.ts b/tests/unit/methodCardFacetMatchesPresetForId.test.ts new file mode 100644 index 0000000..691074f --- /dev/null +++ b/tests/unit/methodCardFacetMatchesPresetForId.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets"; +import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId"; + +const uuid = "550e8400-e29b-41d4-a716-446655440000"; + +describe("methodCardFacetMatchesPresetForId", () => { + it("communication: matches fresh preset seed for an unknown id", () => { + const p = communicationPresetFor(uuid); + expect(communicationMethodFacetMatchesPreset(p, uuid)).toBe(true); + }); + + it("communication: mismatches when any section differs from preset", () => { + const p = communicationPresetFor(uuid); + expect( + communicationMethodFacetMatchesPreset( + { ...p, corePrinciple: "edited" }, + uuid, + ), + ).toBe(false); + }); +}); diff --git a/tests/unit/publishedDocumentToCreateFlowState.test.ts b/tests/unit/publishedDocumentToCreateFlowState.test.ts index 2989735..5adfa28 100644 --- a/tests/unit/publishedDocumentToCreateFlowState.test.ts +++ b/tests/unit/publishedDocumentToCreateFlowState.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createFlowStateFromPublishedRule, + isPublishedRuleHydratePatchIncomplete, isPublishedRuleSelectionMissing, methodSectionsPinsForHydratedSelections, methodSectionsPinsFromPublishedHydratePatch, @@ -68,6 +69,72 @@ describe("isPublishedRuleSelectionMissing", () => { }); }); +describe("isPublishedRuleHydratePatchIncomplete", () => { + it("is true when facet ids are present but custom method meta from patch is missing in state", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000001"; + const patch = createFlowStateFromPublishedRule({ + id: "r", + title: "T", + summary: "", + document: { + methodSelections: { + communication: [ + { + id: customId, + label: "My custom comms method", + sections: { + corePrinciple: "x", + logisticsAdmin: "", + codeOfConduct: "", + }, + }, + ], + }, + }, + }); + const state = { + sections: [], + title: "T", + editingPublishedRuleId: "r", + selectedCommunicationMethodIds: [customId], + } as CreateFlowState; + expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false); + expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(true); + }); + + it("is false when patch meta keys exist on state", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000001"; + const patch = createFlowStateFromPublishedRule({ + id: "r", + title: "T", + summary: "", + document: { + methodSelections: { + communication: [ + { + id: customId, + label: "My custom comms method", + sections: { + corePrinciple: "", + logisticsAdmin: "", + codeOfConduct: "", + }, + }, + ], + }, + }, + }); + const state = { + sections: [], + title: "T", + editingPublishedRuleId: "r", + selectedCommunicationMethodIds: [customId], + customMethodCardMetaById: patch.customMethodCardMetaById, + } as CreateFlowState; + expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(false); + }); +}); + describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => { it("alias matches hydrated-selection helper output", () => { const partial: Partial = { @@ -218,6 +285,35 @@ describe("createFlowStateFromPublishedRule", () => { expect(partial.sections).toEqual([]); }); + it("hydrates customMethodCardMetaById for user-authored method ids from methodSelections", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000001"; + const partial = createFlowStateFromPublishedRule({ + id: "rule-custom", + title: "C", + summary: "", + document: { + methodSelections: { + communication: [ + { + id: customId, + label: "Custom channel policy", + sections: { + corePrinciple: "cp", + logisticsAdmin: "la", + codeOfConduct: "cc", + }, + }, + ], + }, + }, + }); + expect(partial.selectedCommunicationMethodIds).toEqual([customId]); + expect(partial.customMethodCardMetaById?.[customId]).toEqual({ + label: "Custom channel policy", + supportText: "", + }); + }); + it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => { const partial = createFlowStateFromPublishedRule({ id: "rule-2", diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts index adc8625..e258581 100644 --- a/tests/unit/publishedDocumentToDisplaySections.test.ts +++ b/tests/unit/publishedDocumentToDisplaySections.test.ts @@ -116,4 +116,96 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => { doc.sections, ); }); + + it("replaces stale document.sections method category with full methodSelections (custom rules)", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000001"; + const doc = { + sections: [ + { + categoryName: "Communication", + entries: [ + { + title: "Slack", + body: "Only template row; custom card missing from sections.", + }, + ], + }, + ], + methodSelections: { + communication: [ + { + id: "slack", + label: "Slack", + sections: { + corePrinciple: "Slack principle", + logisticsAdmin: "Slack logistics", + codeOfConduct: "Slack conduct", + }, + }, + { + id: customId, + label: "My custom comms", + sections: { + corePrinciple: "Custom principle", + logisticsAdmin: "", + codeOfConduct: "", + }, + }, + ], + }, + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + const comm = out.find((s) => s.categoryName === "Communication"); + expect(comm).toBeDefined(); + expect(comm?.entries.map((e) => e.title)).toEqual([ + "Slack", + "My custom comms", + ]); + expect( + comm?.entries.some( + (e) => e.title === "My custom comms" && e.blocks?.length, + ), + ).toBe(true); + }); + + it("includes wizard field blocks when methodSelections preset sections are empty (custom UUID)", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000001"; + const doc = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Stale template row", body: "ignored after merge" }], + }, + ], + methodSelections: { + communication: [ + { + id: customId, + label: "Custom method title", + sections: { + corePrinciple: "", + logisticsAdmin: "", + codeOfConduct: "", + }, + }, + ], + }, + customMethodCardFieldBlocksById: { + [customId]: [ + { + kind: "text" as const, + id: "f1", + blockTitle: "Expectations", + placeholderText: "Answer stored only on field blocks.", + }, + ], + }, + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + const comm = out.find((s) => s.categoryName === "Communication"); + expect(comm?.entries.map((e) => e.title)).toEqual(["Custom method title"]); + expect(comm?.entries[0]?.blocks).toEqual([ + { label: "Expectations", body: "Answer stored only on field blocks." }, + ]); + }); }); diff --git a/tests/unit/runCompletedStepExit.test.ts b/tests/unit/runCompletedStepExit.test.ts new file mode 100644 index 0000000..939ba30 --- /dev/null +++ b/tests/unit/runCompletedStepExit.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +import { runCompletedStepExit } from "../../app/(app)/create/utils/runCompletedStepExit"; + +describe("runCompletedStepExit", () => { + it("clears client draft mirrors and navigates home without implying server DELETE", () => { + const clearState = vi.fn(); + const clearAnonymousCreateFlowStorage = vi.fn(); + const router = { push: vi.fn() }; + + runCompletedStepExit({ + clearState, + clearAnonymousCreateFlowStorage, + router, + }); + + expect(clearState).toHaveBeenCalledTimes(1); + expect(clearAnonymousCreateFlowStorage).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith("/"); + }); +}); diff --git a/tests/unit/usesWizardFieldBlocksModalBody.test.ts b/tests/unit/usesWizardFieldBlocksModalBody.test.ts new file mode 100644 index 0000000..a2c5028 --- /dev/null +++ b/tests/unit/usesWizardFieldBlocksModalBody.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { usesWizardFieldBlocksModalBody } from "../../lib/create/usesWizardFieldBlocksModalBody"; +import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; + +const id = "550e8400-e29b-41d4-a716-446655440000"; +const meta = { [id]: { label: "L", supportText: "S" } }; +const blocks: CustomMethodCardFieldBlock[] = [ + { kind: "text", id: "b1", blockTitle: "T", placeholderText: "p" }, +]; + +describe("usesWizardFieldBlocksModalBody", () => { + it("is false without meta row", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: "signal", + meta: {}, + fieldBlocksById: {}, + modalEditUnlocked: false, + draftFieldBlocks: null, + }), + ).toBe(false); + }); + + it("is true when persisted field blocks exist (wizard card)", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: { [id]: blocks }, + modalEditUnlocked: false, + draftFieldBlocks: null, + }), + ).toBe(true); + }); + + it("is true for proportion-only persisted blocks (read-only modal)", () => { + const proportionBlocks: CustomMethodCardFieldBlock[] = [ + { + kind: "proportion", + id: "p1", + blockTitle: "Share of async", + defaultPercent: 40, + }, + ]; + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: { [id]: proportionBlocks }, + modalEditUnlocked: false, + draftFieldBlocks: null, + }), + ).toBe(true); + }); + + it("is true when persisted blocks exist even if customMethodCardMetaById row is missing", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta: {}, + fieldBlocksById: { [id]: blocks }, + modalEditUnlocked: false, + draftFieldBlocks: null, + }), + ).toBe(true); + }); + + it("is false when meta exists but persisted blocks empty and not editing blocks (preset duplicate stub)", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: { [id]: [] }, + modalEditUnlocked: false, + draftFieldBlocks: null, + }), + ).toBe(false); + }); + + it("is true in read-only modal when custom, blocks empty, and facet matches preset stubs", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: { [id]: [] }, + modalEditUnlocked: false, + draftFieldBlocks: null, + customFacetDetailsMatchPreset: true, + }), + ).toBe(true); + }); + + it("is false when customizing with empty wizard draft — structured fields stay active", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: {}, + modalEditUnlocked: true, + draftFieldBlocks: [], + }), + ).toBe(false); + }); + + it("is true when customizing with non-empty block draft", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: id, + meta, + fieldBlocksById: {}, + modalEditUnlocked: true, + draftFieldBlocks: blocks, + }), + ).toBe(true); + }); +});